The Smell of Molten Projects in the Morning

Ed Nisley's Blog: Shop notes, electronics, firmware, machinery, 3D printing, laser cuttery, and curiosities. Contents: 100% human thinking, 0% AI slop.

Category: Software

General-purpose computers doing something specific

  • Cylindrical Cell Adapters

    An octet of Eneloop AAA cells arrived, I wanted to measure their as-delivered charge (the package says “Factory Charged With SOLAR ENERGY”, so you know it’s good), and discovered I’d given away my AAA cell holders. You can actually get inter-series adapters on eBay, but what’s the fun in that? Plus, I didn’t want to delay gratification for a month; you know how it is.

    Soooo:

    AAA to AA Adapter - top - Slic3r
    AAA to AA Adapter – top – Slic3r

    It’s basically an AA-size sleeve that fits over the AAA cell, with a lathe-turned brass post conducting juice from the + terminal of the inner cell outward:

    AAA to AA Adapter - parts
    AAA to AA Adapter – parts

    Not much to look at when it’s assembled:

    AAA to AA Adapter - assembled
    AAA to AA Adapter – assembled

    The AAA cell fits deliberately loose, because this goes into a metal clip holding everything firmly in place for the battery tester:

    AAA to AA Adapter - in use
    AAA to AA Adapter – in use

    The source code tabulates the sizes of several cylindrical cells, exactly zero other pairs of which have been tested; I expect most won’t work correctly. In particular, the table entries should include the contact button OD and thickness for each cell, so that I can turn out the proper terminal for each pair of cells. If I ever need a different adapter, I’ll beat some cooperation out of that, too.

    Discovered I needed an adapter after breakfast, started testing cells after lunch. Life is good!

    The OpenSCAD source code as a GitHub Gist:

    // Cylindrical cell adapters
    // Ed Nisley KE4ZNU April 2017
    //- Extrusion parameters must match reality!
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    //———————-
    // Dimensions
    OutCell = "AA"; // cell sizes
    InCell = "AAA";
    BottomClear = 3*ThreadThick; // shorten outer shell to allow base protrusion
    Terminal = [3.0,4.0,2.0]; // terminal: OD = nub dia, length = nub thickness
    NAME = 0;
    ID = 0; // for non-cell cylinders
    OD = 1;
    LENGTH = 2;
    Cells = [
    ["AAAA",8.3,42.5],
    ["AAA",10.5,44.5],
    ["AA",14.5,50.5],
    ["C",26.2,50],
    ["D",34.2,61.5],
    ["A23",10.3,28.5],
    ["CR123",17.0,34.5],
    ["18650",18.6,65.2]
    ];
    Outer = search([OutCell],Cells,1,0)[0];
    Inner = search([InCell],Cells,1,0)[0];
    echo(str("Outer cell: ",Cells[Outer][NAME]));
    echo(str("Inner cell: ",Cells[Inner][NAME]));
    echo(str("Wall: ",Cells[Outer][OD] – (Cells[Inner][OD]/cos(180/NumSides) + 2*ThreadWidth)));
    Delta = Cells[Outer][LENGTH] – Cells[Inner][LENGTH];
    echo(str("Terminal OAL: ",Delta));
    echo(str(" … head: ",Terminal[LENGTH]));
    echo(str(" … shaft: ",Delta – Terminal[LENGTH]));
    NumSides = 3*4;
    //———————-
    // Useful routines
    module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
    Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
    FixDia = Dia / cos(180/Sides);
    cylinder(r=(FixDia + HoleWindage)/2,h=Height,$fn=Sides);
    }
    //———————-
    // Construct adapter
    module Adapter() {
    difference() {
    cylinder(d=Cells[Outer][OD],
    h=Cells[Outer][LENGTH] – BottomClear – Terminal[LENGTH],
    $fn=NumSides);
    translate([0,0,Delta – Terminal[LENGTH]])
    PolyCyl(Cells[Inner][OD] + 2*ThreadWidth,
    Cells[Inner][LENGTH] + Protrusion,
    NumSides);
    translate([0,0,-Protrusion])
    PolyCyl(Terminal[ID],
    2*Cells[Outer][LENGTH],
    6);
    }
    }
    //———————-
    // Build it
    Adapter();

    The original doodle:

    AAA to AA Adapter - sketch
    AAA to AA Adapter – sketch
  • Patient Sign-In FAIL

    We must announce our arrival at the dentist by signing in through a web-based iPad app:

    Dentist iPad sign-in - network fail
    Dentist iPad sign-in – network fail

    You’ll note the signal strength indicator in the upper left shows as much RF as one might reasonably expect from a router within line-of-sight across the room.

    FWIW, I’m getting really tired of the hipster dark-gray on light-gray design ethos.

  • Raspberry Pi Slowdown

    At first, the yard camera worked fine, but a few days later the stream of JPEG images would unpredictably stall. I connect to it through a public-key SSH session and, sometimes, the login would stall for tens of seconds and, with a session set up, various exciting operation like, say, htop would unpredictably stall; if I waited long enough, they’d complete normally.

    This seemed familiar:

    Samsung 16 GB Evo MicroSD card
    Samsung 16 GB Evo MicroSD card

    It’s a known-good card from a reputable supplier, not that that means much these days. The camera flash highlights the gritty silkscreen (?) texture of the orange overlay, but the production value seems high enough to pass muster.

    Popping the card in my desktop PC showed:

    • It remains functional, at least to the extent of being mount-able and write-able
    • 3probe --time-ops /dev/sdb showed it still held 16 GB
    • fsck -fv /dev/sdb[12] shows no problems
    • Both partitions looked good

    So I shrank the main partition to 7.5 GB, copied the image to the desktop PC’s SSD, fired up the Token Windows Laptop, ran the Official SD Card Formatter, and discovered that it thought the card had only 63 MB (yes, MB) available. That’s the size of the FAT boot partition, so I returned the card to the desktop PC, unleashed gparted on it, blew away the partitions, reformatted the whole thing to one 16 GB FAT32 partition, and stuck it back in the laptop, whereupon the Official Formatter agreed it had every byte it should.

    A format-with-overwrite then proceeded apace; the card doesn’t support format-with-erase.

    Back in the desktop, I copied the saved image back onto the card which, en passant, blew away the just-created FAT format and restored the Raspbian partition structure. The 8 GB of that copy proceeded at an average 12.1 MB/s. I did not watch the transfer closely enough to notice any protracted delays.

    Back in the Pi, the card booted and ran perfectly, sending an image every second to the laptop (now running its usual Mint Linux) on the guest network:

    Turkey flock in driveway - 2017-03-21
    Turkey flock in driveway – 2017-03-21

    SSH sessions now work perfectly, too, and commands no longer jam.

    So it seems a good-quality MicroSD card can experience protracted delays while writing data, to the extent of tens of seconds, stalling the Pi in mid-operation without producing data errors or any other symptoms.

    It’s not clear the Official Formatter does anything that simply copying the image back to the card wouldn’t also accomplish, although overwriting the entire 16 GB extent of the card exercises all the cells and forces the card controller to re/de/un/allocate bad blocks. If, indeed, the blocks are bad, rather than just achingly slow.

    Moral of the story: Don’t use MicroSD cards as mass storage devices, at least not for industrial applications that require consistent performance.

  • Vacuum Tube Lights: Duodecar Rebuild

    You’ll recall the LED atop the 21HB5A tube failed, shortly after replacing the bottom LED and rewiring the ersatz plate lead, which led me to rebuild the whole thing with SK6812 RGBW LEDs. So I printed all the plastic parts again, because the duodecar tube socket’s pin circle can fit into a hard drive platter’s unmodified 25 mm hole, then drilled another platter to suit:

    Duodecar disk drilling
    Duodecar disk drilling

    The hole under the drill fits the 3.5 mm stereo socket for the ersatz plate lead, so it’s bigger than before.

    I’ve switched from Arduino Pro Minis with a separate USB converter to Arduino Nanos with an on-board CH340 USB chip, because the fake FTDI chips on the converters are a continuing aggravation:

    21HB5A base - interior
    21HB5A base – interior

    Adding those wire slots to the sockets definitely helps tidy things up; the wires no longer need a crude cable tie anchoring them to the socket mounting screws.

    I wanted to drive the LEDs from the A7 pin, rather than the A3 pin I’d been using on the Pro Minis, to keep the wires closer together, but it turns out that A6 and A7 can’t become digital output pins. So I used A5, although I may come to regret the backward incompatibility.

    In any event, the 21HB5A tube looks spiffy with its new LEDs in full effect:

    21HB5A with RBGBW LEDs - cyan violet phase
    21HB5A with RBGBW LEDs – cyan violet phase

    I dialed the white LED PWM down to 32, making the colors somewhat pastel, rather than washed-out.

    The Arduino source code as a GitHub Gist:

    // Neopixel mood lighting for vacuum tubes
    // Ed Nisley – KE4ANU – June 2016
    // September 2016 – Add Morse library and blinkiness
    // October 2016 – Set random colors at cycle end
    // March 2017 – RGBW SK6812 LEDs
    #include <Adafruit_NeoPixel.h>
    #include <morse.h>
    #include <Entropy.h>
    //———-
    // Pin assignments
    const byte PIN_NEO = A5; // DO – data out to first Neopixel
    const byte PIN_HEARTBEAT = 13; // DO – Arduino LED
    #define PIN_MORSE 12
    //———-
    // Constants
    // number of pixels
    #define PIXELS 2
    // index of the Morse output pixel and how fast it sends
    boolean Send_Morse = false;
    #define PIXEL_MORSE (PIXELS – 1)
    #define MORSE_WPM 10
    // lag between adjacent pixel, degrees of slowest period
    #define PIXELPHASE 45
    // update LEDs only this many ms apart (minus loop() overhead)
    #define UPDATEINTERVAL 50ul
    #define UPDATEMS (UPDATEINTERVAL – 1ul)
    // number of steps per cycle, before applying prime factors
    #define RESOLUTION 500
    //———-
    // Globals
    // instantiate the Neopixel buffer array
    Adafruit_NeoPixel strip = Adafruit_NeoPixel(PIXELS, PIN_NEO, NEO_GRBW + NEO_KHZ800);
    uint32_t FullWhite = strip.Color(255,255,255,255);
    uint32_t FullOff = strip.Color(0,0,0,0);
    uint32_t MorseColor;
    struct pixcolor_t {
    unsigned int Prime;
    unsigned int NumSteps;
    unsigned int Step;
    float StepSize;
    float Phase;
    byte MaxPWM;
    };
    unsigned int PlatterSteps;
    byte PrimeList[] = {3,5,7,13,19,29};
    // colors in each LED
    enum pixcolors {RED, GREEN, BLUE, WHITE, PIXELSIZE};
    struct pixcolor_t Pixels[PIXELSIZE]; // all the data for each pixel color intensity
    uint32_t UniColor;
    unsigned long MillisNow;
    unsigned long MillisThen;
    // Morse code
    char * MorseText = " cq cq cq de ke4znu";
    LEDMorseSender Morse(PIN_MORSE, (float)MORSE_WPM);
    uint8_t PrevMorse, ThisMorse;
    //– Figure PWM based on current state
    byte StepColor(byte Color, float Phi) {
    byte Value;
    Value = (Pixels[Color].MaxPWM / 2.0) * (1.0 + sin(Pixels[Color].Step * Pixels[Color].StepSize + Phi));
    // Value = (Value) ? Value : Pixels[Color].MaxPWM; // flash at dimmest points for debug
    return Value;
    }
    //– Select three unique primes for the color generator function
    // Then compute all the step parameters based on those values
    void SetColorGenerators(void) {
    Pixels[RED].Prime = PrimeList[random(sizeof(PrimeList))];
    do {
    Pixels[GREEN].Prime = PrimeList[random(sizeof(PrimeList))];
    } while (Pixels[RED].Prime == Pixels[GREEN].Prime);
    do {
    Pixels[BLUE].Prime = PrimeList[random(sizeof(PrimeList))];
    } while (Pixels[BLUE].Prime == Pixels[RED].Prime ||
    Pixels[BLUE].Prime == Pixels[GREEN].Prime);
    do {
    Pixels[WHITE].Prime = PrimeList[random(sizeof(PrimeList))];
    } while (Pixels[WHITE].Prime == Pixels[RED].Prime ||
    Pixels[WHITE].Prime == Pixels[GREEN].Prime ||
    Pixels[WHITE].Prime == Pixels[BLUE].Prime);
    printf("Primes: %d %d %d %d\r\n",Pixels[RED].Prime,Pixels[GREEN].Prime,Pixels[BLUE].Prime,Pixels[WHITE].Prime);
    Pixels[RED].MaxPWM = 255;
    Pixels[GREEN].MaxPWM = 255;
    Pixels[BLUE].MaxPWM = 255;
    Pixels[WHITE].MaxPWM = 32;
    unsigned int PhaseSteps = (unsigned int) ((PIXELPHASE / 360.0) *
    RESOLUTION * (unsigned int) max(max(max(Pixels[RED].Prime,Pixels[GREEN].Prime),Pixels[BLUE].Prime),Pixels[WHITE].Prime));
    printf("Pixel phase offset: %d deg = %d steps\r\n",(int)PIXELPHASE,PhaseSteps);
    for (byte c=0; c < PIXELSIZE; c++) {
    Pixels[c].NumSteps = RESOLUTION * Pixels[c].Prime; // steps per cycle
    Pixels[c].StepSize = TWO_PI / Pixels[c].NumSteps; // radians per step
    Pixels[c].Step = random(Pixels[c].NumSteps); // current step
    Pixels[c].Phase = PhaseSteps * Pixels[c].StepSize;; // phase in radians for this color
    printf(" c: %d Steps: %d Init: %d Phase: %d deg",c,Pixels[c].NumSteps,Pixels[c].Step,(int)(Pixels[c].Phase * 360.0 / TWO_PI));
    printf(" PWM: %d\r\n",Pixels[c].MaxPWM);
    }
    }
    //– Helper routine for printf()
    int s_putc(char c, FILE *t) {
    Serial.write(c);
    }
    //——————
    // Set the mood
    void setup() {
    pinMode(PIN_HEARTBEAT,OUTPUT);
    digitalWrite(PIN_HEARTBEAT,LOW); // show we arrived
    Serial.begin(57600);
    fdevopen(&s_putc,0); // set up serial output for printf()
    printf("Vacuum Tube Mood Light – RGBW\r\nEd Nisley – KE4ZNU – March 2017\r\n");
    Entropy.initialize(); // start up entropy collector
    // set up pixels
    strip.begin();
    strip.show();
    // lamp test: a brilliant white flash
    printf("Lamp test: flash white\r\n");
    for (byte i=0; i<5 ; i++) {
    for (int j=0; j < strip.numPixels(); j++) { // fill LEDs with white
    strip.setPixelColor(j,FullWhite);
    }
    strip.show();
    delay(500);
    for (int j=0; j < strip.numPixels(); j++) { // fill LEDs with black
    strip.setPixelColor(j,FullOff);
    }
    strip.show();
    delay(500);
    }
    // get an actual random number
    uint32_t rn = Entropy.random();
    printf("Random seed: %08lx\r\n",rn);
    randomSeed(rn);
    // set up the color generators
    SetColorGenerators();
    // set up Morse generator
    Morse.setup();
    Morse.setMessage(String(MorseText));
    MorseColor = strip.Color(255,random(32,64),random(16),0);
    PrevMorse = ThisMorse = digitalRead(PIN_MORSE);
    printf("Morse enabled: %d at %d wpm color: %08lx\n [%s]\r\n",Send_Morse,MORSE_WPM,MorseColor,MorseText);
    MillisNow = MillisThen = millis();
    }
    //——————
    // Run the mood
    void loop() {
    if (!Morse.continueSending()) {
    printf("Restarting Morse message\r\n");
    Morse.startSending();
    }
    ThisMorse = digitalRead(PIN_MORSE);
    MillisNow = millis();
    if (((MillisNow – MillisThen) >= UPDATEMS) || // time for color change?
    (PrevMorse != ThisMorse)) { // Morse output bit changed?
    digitalWrite(PIN_HEARTBEAT,HIGH);
    if (Send_Morse && ThisMorse) { // if Morse output high, overlay flash
    strip.setPixelColor(PIXEL_MORSE,MorseColor);
    }
    PrevMorse = ThisMorse;
    strip.show(); // send out precomputed colors
    boolean CycleRun = false; // check to see if all cycles have ended
    for (byte c=0; c < PIXELSIZE; c++) { // compute next increment for each color
    if (++Pixels[c].Step >= Pixels[c].NumSteps) {
    Pixels[c].Step = 0;
    printf("Cycle %d steps %d at %8ld delta %ld ms\r\n",c,Pixels[c].NumSteps,MillisNow,(MillisNow – MillisThen));
    }
    else {
    CycleRun = true; // this color is still cycling
    }
    }
    // If all cycles have completed, reset the color generators
    if (!CycleRun) {
    printf("All cycles ended: setting new color generator values\r\n");
    SetColorGenerators();
    }
    for (int i=0; i < strip.numPixels(); i++) { // for each pixel
    byte Value[PIXELSIZE];
    for (byte c=0; c < PIXELSIZE; c++) { // … for each color
    Value[c] = (Pixels[c].MaxPWM / 2.0) * (1.0 + sin(Pixels[c].Step * Pixels[c].StepSize – i*Pixels[c].Phase));
    }
    UniColor = strip.Color(Value[RED],Value[GREEN],Value[BLUE],Value[WHITE]);
    strip.setPixelColor(i,UniColor);
    }
    MillisThen = MillisNow;
    digitalWrite(PIN_HEARTBEAT,LOW);
    }
    }
    view raw TubeMorse.ino hosted with ❤ by GitHub
  • Raspberry Pi Yard Camera

    The yard camera I mentioned a few days ago consists of a Raspberry Pi 3 with an Official V2 Pi Camera peering through two layers of 1955-era window glass into our back yard:

    Back Yard Camera setup - 2017-03-13
    Back Yard Camera setup – 2017-03-13

    Yes, that’s black duct tape holding it to the window pane. The extension cord draped across the floor gotta go, too.

    This being a made-in-haste lashup, I used the streamEye MJPEG HTTP streamer, started from /etc/rc.local in the usual way:

    logger -s Starting camera streamer
    sudo -u pi sh -c '/home/pi/yardcam.sh' &
    logger -s Camera running
    

    The yardcam.sh script feeds one moderate-quality frame to the streamer every second:

    /home/pi/streameye/extras/raspimjpeg.py -w 1280 -h 720 -r 1 -q 80 | streameye
    

    MJPEG has a lot to dislike as a streaming video format. In particular, without any hint of inter-frame compression, the network usage gets way too high for any reasonable frame rate.

    But it got the camera up & running in time for the March snowfall:

    Fun in Snow - 2017-03-15
    Fun in Snow – 2017-03-15

    In a nod to IoT security, the Raspberry Pi’s wireless interface sits behind the router’s firewall on our guest network, with no access to the devices on our main network. The router passes a one-port peephole from the Internet to the Pi, which protects all the other services from unwarranted attention.

    The router maintains a dynamic DNS record with a (not particularly) mnemonic URL, which seems better than an ever-changing dotted-quad IP address.

    Because the router doesn’t support hairpin connections from the main network to the guest network, I can’t monitor the video from my desktop through the outwardly visible URL. Instead, I must fire up a laptop, connect to the guest network, then connect directly to the camera at camera.local.

    You do not have a Need To Know for the URL; I’m sure it’ll appear on Shodan. I plan to take it down when the snow melts.

  • 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)
    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'%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&#39;)
    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','','')
  • SK2812 RGBW LED: Test Fixture

    [Edit: The SK2812 in the title and elsewhere should be SK6812. If I change the title, then all the other links break. So it goes.]

    An envelope of RGBW LEDs, allegedly with SK6812 controllers, arrived from halfway around the planet:

    SK2812RGBW LEDs - as received
    SK2812RGBW LEDs – as received

    The yellow phosphor sauce poured atop the blue LED on the left that makes it glow white leaves the upper loop of two wire bonds sticking out, but I can’t fault ’em for that. The overall build quality looks better than the ill-fated WS2812 LEDs, although it’s hard to tell by looking.

    I conjured a test stand from the vasty digital deep by tweaking the WS2812 mount:

    SK6812 LED Array Test Fixture - Slic3r preview
    SK6812 LED Array Test Fixture – Slic3r preview

    Wiring up a 5×5 panel went as before:

    SK2812RGBW test fixture - rear
    SK2812RGBW test fixture – rear

    The array test code adds another pixel channel and runs another raised sine wave with another random period, accomplished without much hackage.

    With the warm-white LED at full throttle (MaxPWM = 255), the panel tends toward the pallid end of HSV space:

    SK2812RGBW test fixture - front - W PWM255
    SK2812RGBW test fixture – front – W PWM255

    Dialing the white MaxPWM back to 32 crisps things a bit:

    SK2812RGBW test fixture - front - W PWM32
    SK2812RGBW test fixture – front – W PWM32

    Of course, the RGBW data stream isn’t compatible with the RGB data stream, so vacuum tubes with SK6812 chips require a slightly different driver and I can’t mix the two chips on a single tube.

    The Arduino source code as a GitHub Gist:

    // SK6812 RGBW LED array exerciser
    // Ed Nisley – KE4ANU – February 2017
    #include <Adafruit_NeoPixel.h>
    //———-
    // Pin assignments
    const byte PIN_NEO = A3; // DO – data out to first Neopixel
    const byte PIN_HEARTBEAT = 13; // DO – Arduino LED
    //———-
    // Constants
    #define UPDATEINTERVAL 20ul
    const unsigned long UpdateMS = UPDATEINTERVAL – 1ul; // update LEDs only this many ms apart minus loop() overhead
    // number of steps per cycle, before applying prime factors
    #define RESOLUTION 100
    // phase difference between LEDs for slowest color
    #define BASEPHASE (PI/16.0)
    // LEDs in each row
    #define NUMCOLS 5
    // number of rows
    #define NUMROWS 5
    #define NUMPIXELS (NUMCOLS * NUMROWS)
    #define PINDEX(row,col) (row*NUMCOLS + col)
    //———-
    // Globals
    // instantiate the Neopixel buffer array
    Adafruit_NeoPixel strip = Adafruit_NeoPixel(NUMPIXELS, PIN_NEO, NEO_GRBW + NEO_KHZ800);
    uint32_t FullWhite = strip.Color(255,255,255,255);
    uint32_t FullOff = strip.Color(0,0,0,0);
    struct pixcolor_t {
    byte Prime;
    unsigned int NumSteps;
    unsigned int Step;
    float StepSize;
    float TubePhase;
    byte MaxPWM;
    };
    // colors in each LED
    enum pixcolors {RED, GREEN, BLUE, WHITE, PIXELSIZE};
    struct pixcolor_t Pixels[PIXELSIZE]; // all the data for each pixel color intensity
    unsigned long MillisNow;
    unsigned long MillisThen;
    //– Figure PWM based on current state
    byte StepColor(byte Color, float Phi) {
    byte Value;
    Value = (Pixels[Color].MaxPWM / 2.0) * (1.0 + sin(Pixels[Color].Step * Pixels[Color].StepSize + Phi));
    // Value = (Value) ? Value : Pixels[Color].MaxPWM; // flash at dimmest points
    // printf("C: %d Phi: %d Value: %d\r\n",Color,(int)(Phi*180.0/PI),Value);
    return Value;
    }
    //– Helper routine for printf()
    int s_putc(char c, FILE *t) {
    Serial.write(c);
    }
    //——————
    // Set the mood
    void setup() {
    pinMode(PIN_HEARTBEAT,OUTPUT);
    digitalWrite(PIN_HEARTBEAT,LOW); // show we arrived
    Serial.begin(57600);
    fdevopen(&s_putc,0); // set up serial output for printf()
    printf("WS2812 / SK6812 array exerciser\r\nEd Nisley – KE4ZNU – February 2017\r\n");
    /// set up Neopixels
    strip.begin();
    strip.show();
    // lamp test: run a brilliant white dot along the length of the strip
    printf("Lamp test: walking white\r\n");
    strip.setPixelColor(0,FullWhite);
    strip.show();
    delay(250);
    for (int i=1; i<NUMPIXELS; i++) {
    digitalWrite(PIN_HEARTBEAT,HIGH);
    strip.setPixelColor(i-1,FullOff);
    strip.setPixelColor(i,FullWhite);
    strip.show();
    digitalWrite(PIN_HEARTBEAT,LOW);
    delay(250);
    }
    strip.setPixelColor(NUMPIXELS – 1,FullOff);
    strip.show();
    delay(250);
    // fill the array, row by row
    printf(" … fill\r\n");
    for (int i=NUMROWS-1; i>=0; i–) { // for each row
    digitalWrite(PIN_HEARTBEAT,HIGH);
    for (int j=NUMCOLS-1; j>=0 ; j–) {
    strip.setPixelColor(PINDEX(i,j),FullWhite);
    strip.show();
    delay(100);
    }
    digitalWrite(PIN_HEARTBEAT,LOW);
    }
    // clear to black, column by column
    printf(" … clear\r\n");
    for (int j=NUMCOLS-1; j>=0; j–) { // for each column
    digitalWrite(PIN_HEARTBEAT,HIGH);
    for (int i=NUMROWS-1; i>=0; i–) {
    strip.setPixelColor(PINDEX(i,j),FullOff);
    strip.show();
    delay(100);
    }
    digitalWrite(PIN_HEARTBEAT,LOW);
    }
    delay(1000);
    // set up the color generators
    MillisNow = MillisThen = millis();
    printf("First random number: %ld\r\n",random(10));
    Pixels[RED].Prime = 3;
    Pixels[GREEN].Prime = 5;
    Pixels[BLUE].Prime = 7;
    Pixels[WHITE].Prime = 11;
    printf("Primes: (%d,%d,%d,%d)\r\n",
    Pixels[RED].Prime,Pixels[GREEN].Prime,Pixels[BLUE].Prime,Pixels[WHITE].Prime);
    unsigned int PixelSteps = (unsigned int) ((BASEPHASE / TWO_PI) *
    RESOLUTION * (unsigned int) max(max(max(Pixels[RED].Prime,Pixels[GREEN].Prime),Pixels[BLUE].Prime),Pixels[WHITE].Prime));
    printf("Pixel phase offset: %d deg = %d steps\r\n",(int)(BASEPHASE*(360.0/TWO_PI)),PixelSteps);
    Pixels[RED].MaxPWM = 255;
    Pixels[GREEN].MaxPWM = 255;
    Pixels[BLUE].MaxPWM = 255;
    Pixels[WHITE].MaxPWM = 32;
    for (byte c=0; c < PIXELSIZE; c++) {
    Pixels[c].NumSteps = RESOLUTION * (unsigned int) Pixels[c].Prime;
    Pixels[c].Step = (3*Pixels[c].NumSteps)/4;
    Pixels[c].StepSize = TWO_PI / Pixels[c].NumSteps; // in radians per step
    Pixels[c].TubePhase = PixelSteps * Pixels[c].StepSize; // radians per tube
    printf("c: %d Steps: %5d Init: %5d",c,Pixels[c].NumSteps,Pixels[c].Step);
    printf(" PWM: %3d Phi %3d deg\r\n",Pixels[c].MaxPWM,(int)(Pixels[c].TubePhase*(360.0/TWO_PI)));
    }
    }
    //——————
    // Run the mood
    void loop() {
    MillisNow = millis();
    if ((MillisNow – MillisThen) > UpdateMS) {
    digitalWrite(PIN_HEARTBEAT,HIGH);
    unsigned int AllSteps = 0;
    for (byte c=0; c < PIXELSIZE; c++) { // step to next increment in each color
    if (++Pixels[c].Step >= Pixels[c].NumSteps) {
    Pixels[c].Step = 0;
    printf("Color %d steps %5d at %8ld delta %ld ms\r\n",c,Pixels[c].NumSteps,MillisNow,(MillisNow – MillisThen));
    }
    AllSteps += Pixels[c].Step; // will be zero only when all wrap at once
    }
    if (0 == AllSteps) {
    printf("Grand cycle at: %ld\r\n",MillisNow);
    }
    for (int k=0; k < NUMPIXELS; k++) { // for each pixel
    byte Value[PIXELSIZE];
    for (byte c=0; c < PIXELSIZE; c++) { // … for each color
    Value[c] = StepColor(c,-k*Pixels[c].TubePhase); // figure new PWM value
    // Value[c] = (c == RED && Value[c] == 0) ? Pixels[c].MaxPWM : Value[c]; // flash highlight for tracking
    }
    uint32_t UniColor = strip.Color(Value[RED],Value[GREEN],Value[BLUE],Value[WHITE]);
    strip.setPixelColor(k,UniColor);
    }
    strip.show();
    MillisThen = MillisNow;
    digitalWrite(PIN_HEARTBEAT,LOW);
    }
    }