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

  • 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')
    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);
    }
    }

  • ShopVac Hose Barb Adapter

    A small ShopVac arrived with a ribbed hose carrying an absurdly long wand, so I conjured a barbed adapter with a much shorter tapered snout for the machine tools:

    Vacuum hose fittings - hose barb to nozzle
    Vacuum hose fittings – hose barb to nozzle

    Trimming the hose end at one of the ribs makes a tidy fit:

    Vacuum hose fittings - ribbed hose barb
    Vacuum hose fittings – ribbed hose barb

    Now I need not trip over the vacuum hose between the bandsaw bench and the sander bench…

    The OpenSCAD code as a GitHub Gist:

    // Vacuum Hose Fittings
    // Ed Nisley KE4ZNU July 2016
    // March 2017
    Layout = "HoseBarb"; // PVCtoHose ExpandRing PipeToPort FVacPipe FVacFitting HoseBarb
    //- Extrusion parameters must match reality!
    // Print with 2 shells and 3 solid layers
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    Protrusion = 0.1; // make holes end cleanly
    //———————-
    // Dimensions
    ID = 0;
    OD = 1;
    LENGTH = 2;
    VacNozzle = [30.1,31.8,30.0]; // nozzle on vacuum hose (taper ID to OD over length)
    MINOR = 0;
    MAJOR = 1;
    PITCH = 2;
    FORM_OD = 3;
    HoseThread = [32.0,(37.0 + HoleWindage),4.25,(1.8 + 0.20)]; // vacuum hose thread
    NumSegments = 64; // .. number of cylinder approximations per turn
    $fn = NumSegments;
    ThreadLength = 4 * HoseThread[PITCH];
    ScrewOAL = ThreadLength + HoseThread[PITCH];
    WallThick = 2.5;
    echo(str("Pitch dia: ",HoseThread[MAJOR]));
    echo(str("Root dia: ",HoseThread[MAJOR] – HoseThread[FORM_OD]));
    echo(str("Crest dia: ",HoseThread[MAJOR] + HoseThread[FORM_OD]));
    //———————-
    // Wrap cylindrical thread segments around larger plug cylinder
    module CylinderThread(Pitch,Length,PitchDia,ThreadOD,PerTurn,Chirality = "Left") {
    CylFudge = 1.02; // force overlap
    ThreadSides = 6;
    RotIncr = 1/PerTurn;
    PitchRad = PitchDia/2;
    Turns = Length/Pitch;
    NumCyls = Turns*PerTurn;
    ZStep = Pitch / PerTurn;
    HelixAngle = ((Chirality == "Left") ? -1 : 1) * atan(Pitch/(PI*PitchDia));
    CylLength = CylFudge * (PI*(PitchDia + ThreadOD) / PerTurn) / cos(HelixAngle);
    for (i = [0:NumCyls-1]) {
    Angle = ((Chirality == "Left") ? -1 : 1) * 360*i/PerTurn;
    translate([PitchRad*cos(Angle),PitchRad*sin(Angle),i*ZStep])
    rotate([90+HelixAngle,0,Angle]) rotate(180/ThreadSides)
    cylinder(r1=ThreadOD/2,
    r2=ThreadOD/(2*CylFudge),
    h=CylLength,
    center=true,$fn=ThreadSides);
    }
    }
    //– PVC fitting to vacuum hose
    module PVCtoHose() {
    Fitting = [34.0,41.0,16.0]; // 1 inch PVC elbow
    Adapter = [HoseThread[MAJOR],(Fitting[OD] + 2*WallThick + HoleWindage),(ScrewOAL + Fitting[LENGTH])]; // dimensions for entire fitting
    union() {
    difference() {
    cylinder(d=Adapter[OD],h=Adapter[LENGTH]); // overall fitting
    translate([0,0,-Protrusion]) // remove thread pitch dia
    cylinder(d=HoseThread[MAJOR],h=(ScrewOAL + 2*Protrusion));
    translate([0,0,(ScrewOAL – Protrusion)]) // remove PVC fitting dia
    cylinder(d=(Fitting[OD] + HoleWindage),h=(Fitting[LENGTH] + 2*Protrusion));
    }
    translate([0,0,HoseThread[PITCH]/2]) // add the thread form
    CylinderThread(HoseThread[PITCH],ThreadLength,HoseThread[MAJOR],HoseThread[FORM_OD],NumSegments,"Left");
    }
    }
    //– Expander ring from small OD to large ID PVC fittings
    // So a small elbow on the bandsaw fits into the hose adapter, which may not be long-term useful
    module ExpandRing() {
    Fitting_L = [34.0,41.0,16.0]; // 1 inch PVC pipe elbow
    Fitting_S = [26.8,32.8,17]; // 3/4 inch PVC elbow
    difference() {
    cylinder(d1=Fitting_L[OD],d2=(Fitting_L[OD] – HoleWindage),h=Fitting_L[LENGTH]); // overall fitting
    translate([0,0,-Protrusion])
    cylinder(d=(Fitting_S[OD] + HoleWindage),h=(Fitting_L[LENGTH] + 2*Protrusion));
    }
    }
    //– 1 inch PVC pipe into vacuum port
    // Stick this in the port, then plug a fitting onto the pipe section
    module PipeToPort() {
    Pipe = [26.5,33.5,20.0]; // 1 inch Schedule 40 PVC pipe
    difference() {
    union() {
    cylinder(d=Pipe[OD],h=(Pipe[LENGTH] + Protrusion));
    translate([0,0,(Pipe[LENGTH] – Protrusion)])
    cylinder(d1=VacNozzle[OD],d2=VacNozzle[ID],h=VacNozzle[LENGTH]);
    }
    translate([0,0,-Protrusion])
    cylinder(d=Pipe[ID],h=(Pipe[LENGTH] + VacNozzle[LENGTH] + 2*Protrusion));
    }
    }
    //– Female Vac outlet inside PVC pipe
    // Plug this into PVC fitting, then plug hose + nozzle into outlet
    module FVacPipe() {
    VacPort = [30.0,31.3,25]; // vacuum port on belt sander (taper ID to OD over length)
    Pipe = [26.5,33.5,20.0]; // 1 inch Schedule 40 PVC pipe
    difference() {
    cylinder(d=Pipe[OD],h=VacPort[LENGTH]);
    translate([0,0,-Protrusion])
    cylinder(d1=VacPort[ID],d2=VacPort[OD],h=(VacPort[LENGTH] + 2*Protrusion));
    }
    }
    //– Female Vac outlet on 3/4 inch fitting OD
    // Jam this onto OD of fitting, plug hose + nozzle into outlet
    module FVacFitting() {
    Adapter = [26.5,(33.5 + 2*WallThick),17.0]; // overall adapter
    //VacPort = [30.0,31.3,25]; // vacuum port on belt sander (taper ID to OD over length)
    VacPort = [30.1,31.8,30.0]; // vacuum port for bandsaw = inverse of hose nozzle
    Fitting = [26.8,32.8,17]; // 3/4 inch PVC elbow
    TaperLength = 5.0; // inner taper to avoid overhang
    difference() {
    cylinder(d=Adapter[OD],h=Adapter[LENGTH]); // overall fitting
    translate([0,0,-Protrusion])
    cylinder(d=(Fitting[OD] + HoleWindage),h=(Adapter[LENGTH] + 2*Protrusion));
    }
    translate([0,0,Adapter[LENGTH]])
    difference() {
    cylinder(d=Adapter[OD],h=TaperLength);
    translate([0,0,-Protrusion])
    cylinder(d1=(Fitting[OD] + HoleWindage),d2=VacPort[ID],h=(TaperLength + 2*Protrusion));
    }
    translate([0,0,(TaperLength + Adapter[LENGTH])]) // vac fitting
    difference() {
    cylinder(d=Adapter[OD],h=VacPort[LENGTH]);
    translate([0,0,-Protrusion])
    cylinder(d1=VacPort[ID],d2=VacPort[OD],h=(VacPort[LENGTH] + 2*Protrusion));
    }
    }
    //– Hose barb to male vacuum taper
    module HoseBarb() {
    HoseFitting = [29.0,32.2,38.5];
    Barb = [HoseFitting[OD],35.5,4.0];
    BarbOffset = 17.0;
    Seat = [HoseFitting[OD],36.0,5.0];
    SeatSupport = [HoseFitting[OD],Seat[OD],(Seat[OD] – HoseFitting[OD])/2];
    OAL = HoseFitting[LENGTH] + SeatSupport[LENGTH] + Seat[LENGTH] + VacNozzle[LENGTH];
    NumSides = 4*8;
    difference() {
    union() {
    cylinder(d=HoseFitting[OD],h=HoseFitting[LENGTH],$fn=NumSides);
    translate([0,0,BarbOffset])
    cylinder(d1=Barb[ID],d2=Barb[OD],h=Barb[LENGTH],$fn=NumSides);
    translate([0,0,HoseFitting[LENGTH]])
    cylinder(d1=SeatSupport[ID],d2=SeatSupport[OD],h=SeatSupport[LENGTH],$fn=NumSides);
    translate([0,0,HoseFitting[LENGTH] + SeatSupport[LENGTH]])
    cylinder(d=Seat[OD],h=Seat[LENGTH],$fn=NumSides);
    translate([0,0,HoseFitting[LENGTH] + SeatSupport[LENGTH] + Seat[LENGTH]])
    cylinder(d1=VacNozzle[OD],d2=VacNozzle[ID],h=VacNozzle[LENGTH],$fn=NumSides);
    }
    translate([0,0,-Protrusion])
    cylinder(d1=HoseFitting[ID],d2=(VacNozzle[ID] – 10*ThreadWidth),h=OAL + 2*Protrusion,$fn=NumSides);
    }
    }
    //———-
    // Build things
    if (Layout == "PVCtoHose")
    PVCtoHose();
    if (Layout == "ExpandRing") {
    ExpandRing();
    }
    if (Layout == "PipeToPort") {
    PipeToPort();
    }
    if (Layout == "FVacPipe") {
    FVacPipe();
    }
    if (Layout == "FVacFitting") {
    FVacFitting();
    }
    if (Layout == "HoseBarb") {
    HoseBarb();
    }
  • Streaming Radio Advertisements: Carpet Bombing

    After a protracted silence in a Radionomy stream, the Raspberry Pi player offered this log:

    2017-03-05 11:17:07,890 INFO: Starting mplayer on Plenitude -> /home/pi/Playlists/Radio-PLENITUDE.m3u
    2017-03-05 11:17:13,651 INFO: Track name: []
    2017-03-05 11:44:02,296 INFO: Track name: [David Wahler - Whispers from Eternity]
    2017-03-05 11:46:36,995 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 11:47:07,117 INFO: Track name: []
    2017-03-05 11:49:07,080 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 11:49:10,079 INFO: Track name: [Jef Mounet & Danièle Mounet - L'ancre musicale Natures d'Eau]
    2017-03-05 12:02:02,271 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:02:32,424 INFO: Track name: []
    2017-03-05 12:04:32,243 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:05:01,925 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:07:02,276 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:07:31,968 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:09:32,262 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:10:02,192 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:12:02,311 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:12:32,184 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:14:32,085 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:15:02,217 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:17:02,057 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:17:32,445 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:19:32,083 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 12:19:35,171 INFO: Track name: [Jean-Marc Staehle - Bercé par tant de beauté]
    2017-03-05 12:23:42,410 INFO: Track name: [Francesco - Sur le chemin]
    2017-03-05 12:29:50,265 INFO: Track name: [Michel Pépé - Pacifica]
    2017-03-05 12:35:07,493 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:35:37,377 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:37:37,478 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 12:37:41,476 INFO: Track name: [Music And Wellness (Musique Et Bien Etre) - Absolute Winner]
    2017-03-05 12:46:36,742 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 12:47:06,668 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 12:49:06,538 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 12:49:10,270 INFO: Track name: [Patrick Vuillaume &Nicole Bally - Pearls of Light (Instrumental by Nicole Bally)]
    2017-03-05 12:53:45,357 INFO: Track name: [Trine Opsahl - Sister moon]
    2017-03-05 12:54:58,596 INFO: Track name: [Peter Kater - Rebirth]
    2017-03-05 13:04:52,726 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 13:05:22,665 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 13:07:21,561 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 13:07:25,808 INFO: Track name: [Deuter - Flowing]
    2017-03-05 13:12:55,970 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 13:13:25,859 INFO: Track name: []
    2017-03-05 13:15:26,449 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 13:15:33,022 INFO: Track name: [Radio PLENITUDE - Jingle Intro Publicité]
    2017-03-05 13:15:59,437 INFO: Track name: [Targetspot - TargetSpot]
    2017-03-05 13:17:59,559 INFO: Track name: [Radio PLENITUDE - Jingle Extro Publicité]
    2017-03-05 13:18:06,133 INFO: Track name: [O - Part I]
    

    The Jingle lines introduce a short interlude of chimes separating music from advertisements. The Intro chimes play for 30 seconds and the Extro chimes play for three to five seconds. Some stations have similar interludes, others do not; apparently the station gets to choose the format.

    The [Targetspot - TargetSpot] lines mark two minutes of TargetSpot insertion: either advertisements (if you’re in their target market) or generic musical interludes similar to the station’s genre (if you’re out-of-market). The ads and music often lack volume-matching with the streaming music, rarely have lower volume, and the ads are incomprehensible to my ears. The musical interludes seem to be randomly chosen from a small set of candidate tracks that, along with the chimes, become annoyingly familiar in short order.

    The [] lines (yes, an empty string) mark two minutes of Public Service Announcements, advertisements, or generic musical interludes. I’m uncertain how they differ from the [Targetspot - TargetSpot] insertions.

    At a minimum, Radionomy inserts two minutes of TargetSpot / PSAs after every 12 to 15 minutes of music. Adding in the Jingle markers, ads occupy just under 20% of the total “airtime” for this station.

    However, bizarre events like the 17 nonstop minutes of jingles and ads inserted just after noon occur with inexplicable frequency. I’ve noticed half an hour of similar back-to-back-to-back ads on other stations, so it’s not a rare event.

    To quote the TargetSpot website:

    TargetSpot serves ads in real time to each listener’s personalized stream, creating a one-to-one relationship between the advertiser and the listener. The result is a dramatic increase in message relevancy and campaign effectiveness

    Those keyword markers turn out to be incredibly convenient. Just sayin’…

  • Tour Easy Rear Fender Clip

    One of the clips holding the rear fender on my Tour Easy broke:

    Rear fender clip - broken
    Rear fender clip – broken

    Well, if the truth be told, the fender jammed against the tire when I jackknifed the trailer while backing into a parking spot, dragged counterclockwise with the tire, and wiped that little tab right off the block. After 16 years of service, it doesn’t owe me a thing.

    Although the clip around the fender sits a bit lower than it used to (actually, the entire fender sits a bit lower than it should be), you can see the tab had a distinct bend at the edge of the aluminum block supporting the underseat bag frame: the block isn’t perpendicular to the tire / fender at that point.

    After devoting far too long to thinking about how to angle the tab relative to the clip, I realized that I live in the future and can just angle the clip relative to the tab. Soooo, the solid model has a rakish tilt:

    Fender Clip - Slic3r preview
    Fender Clip – Slic3r preview

    The original design had a pair of strain relief struts where the tab meets the clip, but I figured I’ll add those after the PETG fractures.

    I mooched the small bumpouts along the arc from the original design; they provide a bit of stretch & bend so to ease the hooks around the fender.

    The hooks meet the clip with very slight discontinuities that, I think, come from slight differences between the 2D offset() operation and the circle() diameter; the usual 1/cos(180/numsides) trick was unavailing, so I tinkered until the answer came out right.

    Despite those stretchy bumps, it took three iterations, varying the chord height by about 1.5 mm, to securely snap those hooks onto the fender:

    Rear fender clip - 3D printed improvement
    Rear fender clip – 3D printed improvement

    Yeah, sorry ’bout the fuzzy focus on the screw head.

    It’s impossible to measure the chord height accurately enough in that position and I was not going to dismount the rear tire just to get a better measurement.

    You can see how the clip’s rakish tilt matches the fender’s slope, so the tab isn’t bent at all. It’ll probably break at the block the next time I jackknife the trailer, of course.

    I heroically resisted the urge to run off a lower fender mount.

    The OpenSCAD source code as a GitHub Gist:

    // Tour Easy rear fender clip
    // Ed Nisley KE4ZNU February 2017
    Layout = "Build"; // Build Profile Tab Clip
    //- 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
    // special case: fender is exactly half a circle!
    FenderC = 47.0; // fender outside width = chord
    FenderM = 18.5; // height of chord
    FenderR = (pow(FenderM,2) + pow(FenderC,2)/4) / (2 * FenderM); // radius
    echo(str("Fender radius: ", FenderR));
    FenderD = 2*FenderR;
    FenderA = 2 * asin(FenderC / (2*FenderR));
    echo(str(" … arc: ",FenderA," deg"));
    FenderThick = 2.5; // fender thickness, assume dia of edge
    ClipHeight = 18.0; // top to bottom, ignoring rakish tilt
    ClipThick = 3.0; // thickness of clip around fender
    ClipD = FenderD; // ID of clip against
    ClipSides = 4 * 8; // polygon sides around clip circle
    BendReliefD = 2.5; // bend arch diameter
    BendReliefA = 2/3 * FenderA/2; // … angle from dead ahead
    BendReliefCut = 1.0; // factor to thin outside of bend
    TabAngle = -20; // angle from perpendicular to fender
    TabThick = 2.0;
    TabWidth = 15.0;
    ScrewOffset = 15.0; // screw center to fender along perpendicular
    ScrewD = 5.0;
    ScrewSlotLength = 2*ScrewD;
    //———————-
    // 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);
    }
    //———————-
    // Clip profile around fender
    // Centered on fender arc
    module Profile(HeightScale = 1) {
    linear_extrude(height=HeightScale*ClipHeight,convexity=5) {
    difference() {
    offset(r=ClipThick) // outside of clip
    union() {
    circle(d=ClipD,$fn=ClipSides);
    for (i=[-1,1])
    rotate(i*BendReliefA) {
    translate([ClipD/2 + BendReliefD/2,0,0])
    circle(d=BendReliefD,$fn=6);
    }
    }
    union() { // inside of clip
    circle(d=ClipD,$fn=ClipSides);
    for (i=[-1,1])
    rotate(i*BendReliefA) {
    translate([ClipD/2 + BendReliefCut*BendReliefD/2,0,0])
    circle(d=BendReliefD/cos(180/6),$fn=6);
    translate([ClipD/2,0,0])
    square([BendReliefCut*BendReliefD,BendReliefD],center=true);
    }
    }
    translate([(FenderR – FenderM – FenderD/2),0]) // trim ends
    square([FenderD,2*FenderD],center=true);
    }
    for (a=[-1,1]) // hooks around fender
    rotate(a*(FenderA/2))
    translate([FenderR – FenderThick/2,0]) {
    difference() {
    rotate(1*180/12)
    circle(d=FenderThick + 2*ClipThick,$fn=12);
    rotate(1*180/8)
    circle(d=FenderThick,$fn=8);
    rotate(a * -90)
    translate([0,-2*FenderThick,0])
    square(4*FenderThick,center=false);
    }
    }
    }
    }
    //———————-
    // Mounting tab
    module Tab() {
    linear_extrude(height=TabThick,convexity=3)
    difference() {
    hull() {
    circle(d=TabWidth,$fn=ClipSides);
    translate([(ScrewSlotLength – ScrewD)/2 + (FenderR + ScrewOffset),0,0])
    circle(d=TabWidth,$fn=ClipSides);
    }
    circle(d=ClipD,$fn=ClipSides); // remove fender arc
    hull() // screw slot
    for (i=[-1,1])
    translate([i*(ScrewSlotLength – ScrewD)/2 + (FenderR + ScrewOffset),0,0])
    rotate(180/8)
    circle(d=ScrewD/cos(180/8),$fn=8);
    }
    }
    //———————-
    // Combine at mounting angle
    module Clip() {
    difference() {
    union() {
    translate([-FenderR,0,0])
    Tab();
    rotate([0,TabAngle,0])
    translate([-FenderR,0,0])
    Profile(2); // scale upward for trimming
    }
    translate([0,0,-ClipHeight]) // trim bottom
    cube(2*[FenderD,FenderD,ClipHeight],center=true);
    translate([0,0,ClipHeight*cos(TabAngle)+ClipHeight]) // trim top
    cube(2*[FenderD,FenderD,ClipHeight],center=true);
    }
    }
    //———————-
    // Build it
    if (Layout == "Profile") {
    Profile();
    }
    if (Layout == "Tab") {
    Tab();
    }
    if (Layout == "Clip") {
    Clip();
    }
    if (Layout == "Build") {
    Clip();
    }

    The original doodle, with some measurements unable to withstand the test of time:

    Rear Fender Clip - measurement doodles
    Rear Fender Clip – measurement doodles
  • Another Numeric Keypad Snowflake

    I got another batch of wireless keypads that, from the outside, look identical to the previous set:

    Wireless USB Numeric keypads
    Wireless USB Numeric keypads

    The keypad on the right reports Model ID 0x4182, the same as the black plastic batch, and different from the 0x4101 of the previous batch (on the left). Apparently, the small USB dongle carries the Model ID data and the keypads can carry anybody’s logo.

    The Vendor ID, of course, still shows Creative Lab’s 0x062a and all the serial numbers are 1.

    Fortunately, the udev rules already have that combination and the streaming player can’t tell the difference.

    Those labels on the keytops still don’t quite fit, but we’re coping as best we can.

     

     

  • Raspberry Pi 3: Disabling the Build-In WiFi

    streaming media player in the Basement Laboratory Warehouse Wing has a concrete block wall between it and the WiFi router, so that even high(er)-gain USB antennas can’t grab enough signal for reliable streaming. After some fiddling, I snaked a cable from a hub, along the floor joints, to the Pi and declared victory. It turned out the Pi, an old Pi 1 Model B, had some trouble keeping up with the times, and I eventually swapped it for a Pi 3.

    Forcing a static address for the wired port followed the now-standard recipe, with eth0 instead of wlan0 in /etc/dhcpcd.conf.

    However, plugging a network cable into the Pi 3 then produces two network connections: the wired one I wanted and the aforementioned unreliable WiFi link through the built-in hardware. The only reliable way to turn off the WiFi connection seems to require applying The BFH through a line in /etc/rc.local:

    sudo ifconfig wlan0 down
    

    Removing my WiFi credentials from /etc/wpa_supplicant/wpa_supplicant.conf prevents the hardware from connecting before the hammer comes down.

    And then it streams perfectly…