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

  • Torchiere Lamp Shade

    Torchiere Lamp Shade

    A pair of torchiere lamps lit the living room for many, many years:

    Torchiere Lamp Shade - original
    Torchiere Lamp Shade – original

    During their tenure, they’ve gone from 100 W incandescent bulbs to “100 W equivalent” CFL curlicues to “100 W equivalent” warm-white LED bulbs. The LEDs aren’t up to the brightness of the original incandescents, but you can get used to anything if you do it long enough.

    After so many years, the plastic shades / diffusers became brittle:

    Torchiere Lamp Shade - original broken
    Torchiere Lamp Shade – original broken

    That’s after a bump, not a fall to the floor. So it goes.

    Some casual searching didn’t turn up any likely replacements. The shade measures 14 inch = 355 mm across the top, far too large for the M2’s platform, but maybe a smaller shade in natural PETG would work just as well.

    ACHTUNG! This is obviously inappropriate for the original incandescent bulbs and would be, IMO, marginal with CFL tubes. Works fine with LEDs. Your mileage may vary.

    OpenSCAD to the rescue:

    Torchiere Lamp Shade - section
    Torchiere Lamp Shade – section

    That’s a section down the middle. The top is 180 mm across, leaving 20 mm of general caution on the 200 mm width of the platform. The section above the sharply angled base is 90 mm tall to match the actual LED height, thereby putting them out of my line-of-sight even when standing across the room.

    I ran off a short version, corrected the angles and sizes for a better fit, tweaked the thickness to fuse three parallel threads into a semitransparent shell, and …

    Torchiere Lamp Shade - M2 platform
    Torchiere Lamp Shade – M2 platform

    Producing what looks like thin flowerpot required just shy of seven hours of print time, as it’s almost entirely perimeter, goin’ down slow for best appearance. The weird gold tone comes from the interaction of camera flash with warm-white CFL can lights over the desk.

    If you hadn’t met the original, you’d say the new shade grew there:

    Torchiere Lamp Shade - no epoxy
    Torchiere Lamp Shade – no epoxy

    It’s definitely a Brutalist design, not even attempting to hide its 3D printed origin and glorying in those simple geometric facets.

    Those three threads of natural PETG makes a reasonably transparent plate, clear enough that the bulb produced an eye-watering glare through the shade:

    Torchiere Lamp Shade - no epoxy - lit
    Torchiere Lamp Shade – no epoxy – lit

    So I returned it to the Basement Laboratory, chucked it up in the lathe (where it barely clears the bed), dialed the slowest spindle speed (150 rpm according to the laser tach, faster than I’d prefer), and slathered a thin layer of white-tinted XTC-3D around the inside:

    Torchiere Lamp Shade - lathe spinning
    Torchiere Lamp Shade – lathe spinning

    For lack of anything smarter, I mixed 2+ drops of Opaque White with 3.1 g of Part A (resin), added 1.3 g of Part B (Hardener), mixed vigorously, drooled the blob along the middle of the rotating shade, spread it across the width using the mixing stick, smoothed it into a thin layer with a scrap of waxed paper, and ignored it for a few hours.

    If the lathe perspective looks a bit weird, it’s perfectly natural: I raised the tailstock end enough to make the lower side of the shade just about horizontal. Given the gooey nature of XTC-3D, it wasn’t going anywhere, but I didn’t want a slingout across the lathe bed.

    The lit-up result isn’t photographically different from the previous picture, but in person the epoxy layer produces a much nicer diffused light and no glare.

    I might be forced to preemptively replace the other shade, just for symmetry, but we’ll let this one age for a while before jumping to conclusions.

    The OpenSCAD source code as a GitHub Gist:

    // Torchiere Lamp Shade
    // Ed Nisley KE4ZNU – July 2017
    /* [Build] */
    Section = false;
    Shorten = false;
    //- Extrusion parameters – must match reality!
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    Protrusion = 0.01;
    HoleWindage = 0.2;
    //- Dimensions
    ID = 0;
    OD = 1;
    LENGTH = 2;
    /* [Dimensions] */
    ShadeThick = 1.2; // perpendicular thickness
    BaseAngle = 42; // lamp base angle wrt vertical
    BaseTopDia = 131.0; // lamp ID at top
    ShadeBaseThick = 6*ThreadThick; // horizontal bottom thickness
    SeatDepth = 10.0 + ShadeBaseThick; // shade bottom to base top
    SeatDia = BaseTopDia – 2* SeatDepth / tan(BaseAngle); // lamp ID at seating depth
    ShadeTopDia = 180.0; // top OD, limited by printer platform width
    ShadeHeight = 90.0; // height above lamp base
    ShadeHoleDia = 36.0; // central hole dia
    ShadeAngle = atan(ShadeHeight / ((ShadeTopDia – BaseTopDia)/2));
    echo(str("Shade angle: ",ShadeAngle));
    ShadeHThick = ShadeThick / sin(ShadeAngle);
    echo(str(" horiz thickness:",ShadeHThick));
    NumSides = 6*4;
    $fn = NumSides;
    //- Build it
    render(convexity=2)
    difference() {
    union() {
    cylinder(d1=SeatDia,d2=BaseTopDia,h=SeatDepth); // section within lamp base
    translate([0,0,SeatDepth])
    cylinder(d1=BaseTopDia,d2=ShadeTopDia,h=ShadeHeight);
    }
    translate([0,0,SeatDepth]) // inside of upper shade
    cylinder(d1=BaseTopDia – 2*ShadeHThick,
    d2=ShadeTopDia – 2*ShadeHThick,
    h=ShadeHeight + Protrusion);
    translate([0,0,ShadeBaseThick]) // seating base
    cylinder(d1=SeatDia – 2*ShadeHThick,
    d2=BaseTopDia – 2*ShadeHThick,
    h=SeatDepth – ShadeBaseThick + Protrusion);
    translate([0,0,-Protrusion]) // socket clearance
    cylinder(d=ShadeHoleDia,h=2*ShadeHeight);
    if (Section)
    translate([0,-ShadeTopDia,0])
    cube(2*ShadeTopDia,center=true);
    if (Shorten > 0)
    translate([0,0,(ShadeTopDia + 2*SeatDepth)])
    cube(2*ShadeTopDia,center=true);
    }
  • Optiplex 9010: Xsetwacom vs. Dual Monitors

    Having replaced the Dell Optiplex 980 (running from an eBay NOS power supply) with an off-lease Optiplex 9010, I was mildly surprised to find two Displayport outputs from the built-in Intel graphics chipset. Not being a gamer, I don’t care much about graphic performance, but plugging two 2560×1440 monitors into the jacks and having them Just Work was delightful. Indeed, Dell even managed to fix work around the error in the U2711  firmware requiring me to power-cycle the damned thing before booting the PC; now I can just turn the PC on and It Just Works.

    Mysteriously, the incantation required to limit the Wacom tablet to the left-hand landscape monitor now uses DP1 instead of HEAD-0:

    xsetwacom --verbose set "Wacom Graphire3 6x8 stylus" MapToOutput "DP1"
    xsetwacom --verbose set "Wacom Graphire3 6x8 eraser" MapToOutput "DP1"
    #xsetwacom --verbose set "Wacom Graphire3 6x8 Pen stylus" MapToOutput "HEAD-0"
    #xsetwacom --verbose set "Wacom Graphire3 6x8 Pen eraser" MapToOutput "HEAD-0"
    

    I’ll leave the “HEAD-0 incantations as comments, so as to have a hint the next time …

  • Raspberry Pi vs. Music via NFS

    Every now & again, streaming music from distant servers fails, for no reason I can determine. In that situation, it would be nice to have a local source and, as mplayer works just fine when aimed at an MP3 file, I tried to set up a USB stick on the ASUS router.

    That requires getting their version of SAMBA working with the Raspbian Lite installed on the streaming players. After screwing around for far too long, I finally admitted defeat, popped the USB stick into the Raspberry Pi running the APRS iGate in the attic stairwell, and configured it as an NFS server.

    To slightly complicate the discussion, there’s also a file server in the basement which turns itself off after its nightly backup. The local music files must be available when it’s off, so the always-up iGate machine gets the job.

    On the NFS server:

    Install rpcbind and nfs-common, both of which should already be included in stock Raspbian Lite, and nfs-kernel-server, which isn’t. There were problems with earlier Raspbian versions involving the startup order which should be history by now; this post may remind me what’s needed in the event the iGate NFS server wakes up dead after the next power blink.

    Set up /etc/exports to share the mount point:

    /mnt/music	*(ro,async,insecure,no_subtree_check)
    # blank line so you can see the underscores in the previous one
    

    Plug in the USB stick, mount, copy various music directories from the file server’s pile o’ music to the stick’s root directory.

    Create a playlist from the directory entries and maybe edit it a bit:

    ls -1 /mnt/part/The_Music_Directory > playlist.tmp
    sed 's/this/that/' < playlist.tmp > playlist.txt
    rm playlist.tmp
    

    Tuck the playlist into the Playlists directory on the basement file server, from whence the streamer’s /etc/rc.local will copy the file to its local directory during the next boot.

    On every streamer, create the /mnt/music mountpoint and edit /etc/rc.local to mount the directory:

    nfs_music=192.168.1.110
    <<< snippage >>>
    mount -v -o ro $nfs_music:/mnt/music /mnt/music
    # blank line so you can see the underscores in the previous one 

    In the Python streaming program on the file server, associate the new “station” with a button:

             'KEY_KP8'   : ['Newname',False,['mplayer','-shuffle','-playlist','/home/pi/Playlists/playlist.txt']],
    

    The startup script also fetches the latest copy of the Python program whenever the file server is up, so the new version should Just Work.

    I set the numeric keypad button associated with that program as the fallback in case of stream failures, so when the Interwebs go down, we still have music. Life is good …

  • Tour Easy Daytime Running Light

    Pending more test rides, the flashlight fairing mount works well:

    Tour Easy Fairing Flashlight Mount - front overview
    Tour Easy Fairing Flashlight Mount – front overview

    Despite all my fussing with three rotational angles, simply tilting the mount upward by 20° with respect to the fairing clamp aims the flashlight straight ahead, with the ball nearly centered in the clamp:

    Tour Easy Fairing Flashlight Mount - front detail
    Tour Easy Fairing Flashlight Mount – front detail

    That obviously depends on the handlebar angle and the fairing length (which affects the strut rotation), but it’s close enough to make me think a simpler mount will suffice: clamp the flashlight into a cylinder with a slight offset angle, maybe 2°, then mount the cylinder into a much thinner ring clamp at the 20° tilt. Rotating the cylinder would give you some aim-ability, minus the bulk of a ball mount.

    Or dispense with the separate cylinder, build the entire mount at the (now known) aim angle, clamp the flashlight directly into the mount, then affix mount to fairing strut. Rapid prototyping FTW!

    For now, it’s great riding weather …

    The OpenSCAD source code as a GitHub Gist:

    // Tour Easy Fairing Flashlight Mount
    // Ed Nisley KE4ZNU – July 2017
    /* [Build Options] */
    FlashName = "AnkerLC40"; // [AnkerLC40,AnkerLC90,J5TactV2,InnovaX5]
    Component = "Mount"; // [Ball, BallClamp, Mount, Plates, Bracket]
    Layout = "Show"; // [Build, Show]
    Support = false;
    MountSupport = true;
    /* [Extrusion] */
    ThreadThick = 0.25; // [0.20, 0.25]
    ThreadWidth = 0.40; // [0.40]
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    Protrusion = 0.01; // [0.01, 0.1]
    HoleWindage = 0.2;
    /* [Fairing Mount] */
    ToeIn = 0; // inward from ahead
    Tilt = 20; // upward from forward
    Roll = 0; // outward from top
    Shift = -5; // realign to plate center
    //- Screws *c
    /* [Hidden] */
    ID = 0;
    OD = 1;
    LENGTH = 2;
    /* [Screws and Inserts] */
    BallInsert = [2.0,3.5,4.0];
    BallScrew = [2.0,3.5,2.0];
    ClampInsert = [3.0,4.2,8.0];
    ClampScrew = [3.0,5.9,50.0]; // thread dia, head OD, screw length
    ClampScrewWasher = [3.0,6.75,0.5];
    ClampScrewNut = [3.0,6.1,4.0]; // nyloc nut
    /* [Hidden] */
    F_NAME = 0;
    F_GRIPOD = 1;
    F_GRIPLEN = 2;
    LightBodies = [
    ["AnkerLC90",26.6,48.0],
    ["AnkerLC40",26.6,55.0],
    ["J5TactV2",25.0,30.0],
    ["InnovaX5",22.0,55.0]
    ];
    NumSides = 8*4;
    echo(str("Flashlight: ",FlashName));
    FlashIndex = search([FlashName],LightBodies,1,0)[F_NAME];
    BallThick = IntegerMultiple(5.0,ThreadWidth); // thickness of ball wall
    echo(str("Ball wall: ",BallThick));
    BallOD = max(45,IntegerMultiple(LightBodies[FlashIndex][F_GRIPOD] + 2*(BallThick + BallInsert[OD]),2.0));
    echo(str(" OD: ",BallOD));
    BallScrewOC = BallOD – BallThick – BallInsert[OD]; // from OD to allow different body diameters
    echo(str(" screw OC: ",BallScrewOC));
    BallLength = min(sqrt(pow(BallOD,2) – pow(LightBodies[FlashIndex][F_GRIPOD],2)),
    LightBodies[FlashIndex][F_GRIPLEN]);
    echo(str(" hole len: ",BallLength));
    ClampThick = 2*ClampInsert[OD];
    echo(str("Clamp wall: ",ClampThick));
    ClampOD = BallOD + 2*ClampThick;
    echo(str(" OD: ",ClampOD));
    ClampScrewOC = BallOD + 2*ClampInsert[OD];
    echo(str(" screw OC: ",ClampScrewOC));
    ClampLength = 0.70 * BallLength;
    echo(str(" length: ",ClampLength));
    //- Adjust hole diameter to make the size come out right
    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);
    }
    //- Ball around flashlight
    // Must print two!
    module BodyBall() {
    difference() {
    intersection() {
    sphere(d=BallOD,$fn=2*NumSides); // basic ball
    cube([BallLength,2*BallOD,2*BallOD],center=true); // max of flashlight grip length
    }
    translate([-LightBodies[FlashIndex][F_GRIPOD],0,0])
    rotate([0,90,0]) rotate(180/NumSides)
    PolyCyl(LightBodies[FlashIndex][F_GRIPOD],2*BallOD,NumSides); // flashlight body
    for (j=[-1,1])
    translate([0,j*BallScrewOC/2,0]) // commmon screw offset
    translate([0,0,-BallOD])
    PolyCyl(BallInsert[ID],2*BallOD,6); // punch screw shaft through everything
    translate([0,BallScrewOC/2,-Protrusion])
    PolyCyl(BallInsert[OD],(BallInsert[LENGTH] + 3*ThreadThick + Protrusion),6); // threaded insert
    translate([0,-BallScrewOC/2,BallThick])
    PolyCyl(BallScrew[OD],BallOD,6); // screw head clearance
    translate([0,0,-BallOD/2]) // remove bottom half
    cube(BallOD,center=true);
    translate([0,0,BallOD – BallThick/2]) // slice off top = bottom for E-Z build
    cube(BallOD,center=true);
    }
    if (Support) {
    NumRibs = 24;
    RibHeight = (BallOD – LightBodies[FlashIndex][F_GRIPOD]/cos(180/NumSides) – BallThick) / 2;
    ChordC = 2*sqrt(BallThick*BallOD/2 – pow(BallThick/2,2));
    intersection() {
    cube([BallLength,2*BallOD,2*BallOD],center=true); // max of flashlight grip length
    translate([0,0,BallOD/2 – BallThick/2])
    for (i=[0:NumRibs – 1])
    rotate(i*360/NumRibs + 180/NumRibs) // avoid screw holes
    translate([ChordC/2 + BallOD/8,0,-RibHeight/2])
    cube([BallOD/4,2*ThreadWidth,RibHeight],center=true);
    }
    }
    }
    //- Fairing Bracket
    // Magic numbers taken from the actual fairing mount
    // Centered on screw hole
    /* [Hidden] */
    inch = 25.4;
    BracketHoleOD = 0.25 * inch; // 1/4-20 bolt holes
    BracketHoleOC = 1.0 * inch; // fairing hole spacing
    // usually 1 inch, but 15/16 on one fairing
    Bracket = [48.0,16.3,3.6 – 0.6]; // fairing bracket end plate overall size
    BracketHoleOffset = (3/8) * inch; // end to hole center
    BracketM = 3.0; // endcap arc height
    BracketR = (pow(BracketM,2) + pow(Bracket[1],2)/4) / (2*BracketM); // … radius
    module Bracket() {
    linear_extrude(height=Bracket[2],convexity=2)
    difference() {
    translate([(Bracket[0]/2 – BracketHoleOffset),0,0])
    offset(delta=ThreadWidth)
    intersection() {
    square([Bracket[0],Bracket[1]],center=true);
    union() {
    for (i=[-1,0,1]) // middle circle fills gap
    translate([i*(Bracket[0]/2 – BracketR),0])
    circle(r=BracketR);
    }
    }
    circle(d=BracketHoleOD/cos(180/8),$fn=8); // dead center at the origin
    }
    }
    //- General plate shape
    // Centered on the hole for the fairing bracket
    Plate = [100.0,30.0,6*ThreadThick + Bracket[2]];
    PlateRad = Plate[1]/4;
    echo(str("Base plate thick: ",Plate[2]));
    module PlateBlank() {
    difference() {
    translate([BracketHoleOC,0,0])
    intersection() {
    translate([0,0,Plate[2]/2]) // select upper half of spheres
    cube(Plate,center=true);
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*(Plate[0]/2 – PlateRad),j*(Plate[1]/2 – PlateRad),0])
    resize([2*PlateRad,2*PlateRad,2*Plate[2]])
    sphere(r=PlateRad); // nice rounded corners!
    }
    translate([2*BracketHoleOC,0,-Protrusion]) // screw holes
    PolyCyl(BracketHoleOD,2*Plate[2],8);
    translate([0,0,-Protrusion])
    PolyCyl(BracketHoleOD,2*Plate[2],8);
    }
    }
    //- Inner plate
    module InnerPlate() {
    difference() {
    PlateBlank();
    translate([0,0,Plate[2] – Bracket[2] + Protrusion]) // punch out fairing bracket
    Bracket();
    }
    }
    //- Clamp around flashlight ball
    module BallClamp() {
    BossLength = ClampScrew[LENGTH] – ClampScrewNut[LENGTH] – 2*ClampScrewWasher[LENGTH] – 4*ThreadThick;
    difference() {
    union() {
    intersection() {
    sphere(d=ClampOD,$fn=NumSides); // exterior ball blamp
    cube([ClampLength,2*ClampOD,2*ClampOD],center=true); // aiming allowance
    }
    for (i=[0])
    hull() {
    for (j=[-1,1])
    translate([i*(ClampLength/2 – ClampScrew[OD]),j*ClampScrewOC/2,-BossLength/2])
    rotate(180/8)
    cylinder(d=(ClampScrewWasher[OD] + 2*ThreadWidth),h=BossLength,$fn=8);
    }
    }
    sphere(d=(BallOD + 1*ThreadThick),$fn=NumSides); // interior ball
    for (i=[0] , j=[-1,1]) {
    translate([i*(ClampLength/2 – ClampScrew[OD]),j*ClampScrewOC/2,-ClampOD]) // screw clearance
    rotate(180/8)
    PolyCyl(ClampScrew[ID],2*ClampOD,8);
    }
    }
    color("Yellow")
    if (Support) { // ad-hoc supports for top half
    NumRibs = 6;
    RibLength = 0.5 * BallOD;
    RibWidth = 1.9*ThreadWidth;
    SupportOC = ClampLength / NumRibs;
    cube([ClampLength,RibLength,4*ThreadThick],center=true); // base plate for adhesion
    intersection() {
    sphere(d=BallOD – 0*ThreadWidth); // cut at inner sphere OD
    cube([ClampLength + 2*ThreadWidth,RibLength,BallOD],center=true);
    union() { // ribs for E-Z build
    for (j=[-1,0,1])
    translate([0,j*SupportOC,0])
    cube([ClampLength,RibWidth,1.0*BallOD],center=true);
    for (i=[0:NumRibs]) // allow +1 to fill the far end
    translate([i*SupportOC – ClampLength/2,0,0])
    rotate([0,90,0])
    cylinder(d=BallOD – 2*ThreadThick,
    h=RibWidth,$fn=NumSides,center=true);
    }
    }
    }
    }
    //- Mount between fairing plate and flashlight ball
    module Mount() {
    translate([-BracketHoleOC,0,0])
    PlateBlank();
    translate([Shift,0,ClampOD/2])
    rotate([-Roll,ToeIn,Tilt])
    intersection() {
    translate([0,0,-ClampOD/2])
    cube([2*ClampOD,2*ClampOD,ClampOD],center=true);
    BallClamp();
    }
    if (MountSupport) { // anchor outer corners during worst overhang
    RibWidth = 1.9*ThreadWidth;
    SupportOC = 0.1 * ClampLength;
    difference() {
    rotate([0,0,Tilt])
    translate([Shift + 0.3,0,0])
    for (i=[-4.5,-2.5,0,2.0,4.5])
    translate([i*SupportOC – 0.0,0,(ClampThick + Plate[2])/2])
    cube([RibWidth,0.8*ClampOD,(ClampThick + Plate[2])],center=true);
    # translate([Shift,0,ClampOD/2])
    rotate([-Roll,ToeIn,Tilt])
    sphere(d=ClampOD – 2*ThreadWidth,$fn=NumSides);
    }
    }
    }
    //- Build things
    if (Component == "Ball")
    if (Layout == "Show")
    BodyBall();
    else if (Layout == "Build") {
    translate([0,+1*(BallOD/2 + BallThick/2),0])
    translate([0,0,BallOD/2 – BallThick/2])
    rotate([180,0,0])
    BodyBall();
    translate([0,-1*(BallOD/2 + BallThick/2),0])
    translate([0,0,BallOD/2 – BallThick/2])
    rotate([180,0,0])
    BodyBall();
    }
    if (Component == "BallClamp")
    if (Layout == "Show")
    BallClamp();
    else if (Layout == "Build") {
    Both = false;
    difference() {
    union() {
    translate([Both ? ClampLength : 0,0,0])
    BallClamp();
    if (Both)
    translate([-ClampLength,0,0])
    rotate([180,0,0])
    BallClamp();
    }
    translate([0,0,-ClampOD/2])
    cube([2*ClampOD,2*ClampOD,ClampOD],center=true);
    }
    }
    if (Component == "Mount")
    Mount();
    if (Component == "Plates") {
    translate([0,0.7*Plate[1],0])
    InnerPlate();
    translate([0,-0.7*Plate[1],0])
    PlateBlank();
    }
    if (Component == "Bracket")
    Bracket();

  • Cylindrical Cell Adapter: 18650 to 3xAAA

    Anker LC40 flashlights can use either one lithium 18650 cell or an adapter holding three AAA cells. I now prefer 18650 cells, but they’re nigh onto 4 mm smaller than the flashlight ID and rattle around something awful.

    I can fix that:

    Anker LC40 with 18650 cell adapter
    Anker LC40 with 18650 cell adapter

    Three new entries appear in the cell dimension table of my OpenSCAD inter-series battery adapter program:

    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],
      ["CR123A",17.0,34.5],
      ["18650",18.8,65.2],
      ["3xAAA",21.2,56.0],
      ["AnkerLC40",23.0,55.0]           // Flashlight tube loose-fit for 3xAAA adapter
    ];
    

    I took the opportunity of adding OpenSCAD Customizer comments, which means this now works:

    OpenSCAD Customizer - dropdown selections
    OpenSCAD Customizer – dropdown selections

    The model looks about the same as before, although with a few more sides just for pretty:

    AnkerLC40 vs. 18650 Sleeve - Slic3r
    AnkerLC40 vs. 18650 Sleeve – Slic3r

    That was easy …

  • LF Crystal Tester: Joystick for Oscillator Offset Adjustment

    With the joystick button and LM75 temperature sensor running, this chunk of code lets you nudge the nominal DDS oscillator frequency by 1 Hz every 100 ms:

    // Zero-beat oscillator to 10 MHz GPS-locked reference
    printf("Zero beat DDS oscillator against GPS\n");
    TempFreq.fx_64 = CALFREQ;
    u8x8.clearDisplay();
    byte ln = 0;
    u8x8.drawString(0,ln++,"10 MHz Zero Beat");
    u8x8.drawString(0,ln++,"<- Joystick ->");
    u8x8.drawString(0,ln++," Button = set ");
    int32_t OldOffset = OscOffset;
    while (analogRead(PIN_JOYBUTTTON) > 500) {
    int ai = analogRead(PIN_JOY_Y) – 512; // totally ad-hoc axes
    if (ai < -100) {
    OscOffset += 1;
    }
    else if (ai > 100) {
    OscOffset -= 1;
    }
    if (OscOffset != OldOffset) {
    ln = 4;
    sprintf(Buffer,"Offset %8ld",OscOffset);
    u8x8.drawString(0,ln++,Buffer);
    CalcOscillator(OscOffset); // recalculate constants
    TempCount.fx_64 = MultiplyFixedPt(TempFreq,CtPerHz); // recalculate delta phase count
    WriteDDS(TempCount.fx_32.high); // should be 10 MHz out!
    OldOffset = OscOffset;
    }
    Wire.requestFrom(LM75_ADDR,2);
    Temperature.fx_32.high = Wire.read();
    Temperature.fx_32.low = (uint32_t)Wire.read() << 24;
    PrintFixedPtRounded(Buffer,Temperature,3);
    ln = 7;
    u8x8.drawString(0,ln,"DDS Temp");
    u8x8.drawString(16-strlen(Buffer),ln++,Buffer);
    delay(100);
    }
    printf("Oscillator offset: %ld\n",OscOffset);
    view raw gistfile1.txt hosted with ❤ by GitHub

    While that’s happening, you compare the DDS output to a reference frequency on an oscilloscope:

    Zero-beat oscillator
    Zero-beat oscillator

    The top trace (and scope trigger) is the GPS-locked 10 MHz reference, the lower trace is the AD9850 DDS output (not through the MAX4165 buffer amp, because bandwidth). If the frequencies aren’t identical, the DDS trace will crawl left or right with respect to the reference: leftward if the DDS frequency is too high, rightward if it’s too low. If the DDS frequency is way off, then the waveform may scamper or run, with the distinct possibility of aliasing on digital scopes; you have been warned.

    The joystick acts as a bidirectional switch, rather than an analog input, with the loop determining the step increment and timing. The ad-hoc axis orientation lets you (well, me) push the joystick against the waveform crawl, which gradually slows down and stops when the offset value makes the DDS output match the reference.

    The OLED displays the current status:

    DDS Offset zero-beat display
    DDS Offset zero-beat display

    The lurid red glow along the bottom is lens flare from the amber LED showing the relay is turned on. The slightly dimmer characters across the middle of the display show how the refresh interacts with the camera shutter at 1/30 s exposure.

    N.B.: Normally, you know the DDS clock oscillator frequency with some accuracy. Dividing that value into 232 (for the AD9850) gives you the delta-phase count / frequency ratio that converts a desired DDS output frequency into the delta-phase value telling the DDS to make it happen.

    In this case, I want the output frequency to be exactly 10.000000 MHz, so I’m adjusting the oscillator frequency (nominal 125 MHz + offset), calculating the corresponding count-to-Hz ratio, multiplying the ratio by 10.000000 MHz, stuffing the ensuing count into the DDS, and eyeballing what happens. When the oscillator frequency variable matches the actual oscillator frequency, then the actual output will 10.000000 MHz and the ratio will be correct.

    Got it? Took me a while.

    Although the intent is to tune for best frequency match and move on, you (well, I) can use this to accumulate a table of frequency offset vs. temperature pairs, from which a (presumably simple) formula can be conjured to render this step unnecessary.

    The Arduino source code as a GitHub Gist:

    // 60 kHz crystal tester
    // Ed Nisley – KE4ZNU
    #include <avr/pgmspace.h>
    #include <U8g2lib.h>
    #include <U8x8lib.h>
    #include <Adafruit_MCP4725.h>
    //———————
    // Pin locations
    #define PIN_SYNC 5
    #define PIN_CX_SHORT 6
    #define PIN_DDS_RESET 7
    #define PIN_DDS_LATCH 8
    #define PIN_HEARTBEAT 9
    #define PIN_LOG_AMP A0
    #define PIN_JOYBUTTTON A1
    #define PIN_JOY_Y A2
    #define PIN_JOY_X A3
    // SPI & I2C use hardware support: these pins are predetermined
    #define PIN_SS 10
    #define PIN_MOSI 11
    #define PIN_MISO 12
    #define PIN_SCK 13
    #define PIN_IIC_SDA A4
    #define PIN_IIC_SCL A5
    // IIC Hardware addresses
    // OLED library uses its default address
    #define LM75_ADDR 0x48
    #define SH1106_ADDR 0x70
    #define MCP4725_ADDR 0x60
    // Useful constants
    #define GIGA 1000000000LL
    #define MEGA 1000000LL
    #define KILO 1000LL
    #define ONE_FX (1LL << 32)
    #define CALFREQ (10LL * MEGA * ONE_FX)
    // Structures for 64-bit fixed point numbers
    // Low word = fractional part
    // High word = integer part
    struct ll_fx {
    uint32_t low; // fractional part
    uint32_t high; // integer part
    };
    union ll_u {
    uint64_t fx_64;
    struct ll_fx fx_32;
    };
    // Define semi-constant values
    union ll_u CenterFreq = {(60000 – 4) * ONE_FX}; // center of scan
    //union ll_u CenterFreq = {(32768 – 2) * ONE_FX}; // center of scan
    #define NOMINAL_OSC ((125 * MEGA) * ONE_FX)
    union ll_u Oscillator = {NOMINAL_OSC}; // oscillator frequency
    int32_t OscOffset = -414; // measured offset from NOMINAL_OSC
    uint16_t ScanWidth = 4*2; // width must be an even integer
    uint16_t ScanSettleMS = 2000; // milliseconds of settling time per measurement
    union ll_u ScanStepSize = {ONE_FX / 10}; // 0.1 Hz is smallest practical decimal step
    //union ll_u ScanStepSize = {ONE_FX / 34}; // 0.0291 is smallest possible step
    // Global variables of interest to everyone
    union ll_u ScanFrom, ScanTo; // may be larger than unsigned ints
    union ll_u ScanFreq; // fixed-point frequency scan settings
    union ll_u PeakFreq; // records maximum response point
    union ll_u PeakdB; // and corresponding log amp output
    union ll_u SeriesPeakLow,SeriesPeakHigh; // peak with CX short and CX in circuit
    union ll_u CtPerHz; // will be 2^32 / oscillator
    union ll_u HzPerCt; // will be oscillator / 2^32
    char Buffer[10+1+10+1]; // string buffer for fixed point number conversions
    union ll_u Temperature; // read from LM75A
    // Hardware library variables
    U8X8_SH1106_128X64_NONAME_HW_I2C u8x8(U8X8_PIN_NONE);
    //U8X8_SH1106_128X64_NONAME_4W_HW_SPI u8x8(PIN_DISP_SEL, PIN_DISP_DC , PIN_DISP_RST);
    //U8X8_SH1106_128X64_NONAME_4W_SW_SPI u8x8(PIN_SCK, PIN_MOSI, PIN_DISP_SEL, PIN_DISP_DC , PIN_DISP_RST);
    #define DAC_WR false
    #define DAC_WR_EEP true
    #define DAC_BITS 12
    #define DAC_MAX 0x0fff
    Adafruit_MCP4725 XAxisDAC; // I²C DAC for X axis output
    uint32_t XAxisValue; // DAC parameter uses 32 bits
    union ll_u LogAmpdB; // computed dB value
    #define HEARTBEAT_MS 3000
    unsigned long MillisNow,MillisThen;
    //———–
    // Useful functions
    // Pin twiddling
    void TogglePin(char bitpin) {
    digitalWrite(bitpin,!digitalRead(bitpin)); // toggle the bit based on previous output
    }
    void PulsePin(char bitpin) {
    TogglePin(bitpin);
    TogglePin(bitpin);
    }
    void WaitButtonDown() {
    word ai;
    do {
    ai = analogRead(PIN_JOYBUTTTON);
    } while (ai > 500);
    }
    void WaitButtonUp() {
    word ai;
    do {
    ai = analogRead(PIN_JOYBUTTTON);
    } while (ai < 500);
    }
    // Hardware-assisted SPI I/O
    void EnableSPI(void) {
    digitalWrite(PIN_SS,HIGH); // set SPI into Master mode
    SPCR |= 1 << SPE;
    }
    void DisableSPI(void) {
    SPCR &= ~(1 << SPE);
    }
    void WaitSPIF(void) {
    while (! (SPSR & (1 << SPIF))) {
    // TogglePin(PIN_HEARTBEAT);
    // TogglePin(PIN_HEARTBEAT);
    continue;
    }
    }
    byte SendRecSPI(byte Dbyte) { // send one byte, get another in exchange
    SPDR = Dbyte;
    WaitSPIF();
    return SPDR; // SPIF will be cleared
    }
    //————–
    // DDS module
    void EnableDDS(void) {
    digitalWrite(PIN_DDS_LATCH,LOW); // ensure proper startup
    digitalWrite(PIN_DDS_RESET,HIGH); // minimum reset pulse 40 ns, not a problem
    digitalWrite(PIN_DDS_RESET,LOW);
    delayMicroseconds(1); // max latency 100 ns, not a problem
    DisableSPI(); // allow manual control of outputs
    digitalWrite(PIN_SCK,LOW); // ensure clean SCK pulse
    PulsePin(PIN_SCK); // … to latch hardwired config bits
    PulsePin(PIN_DDS_LATCH); // load hardwired config bits = begin serial mode
    EnableSPI(); // turn on hardware SPI controls
    SendRecSPI(0x00); // shift in serial config bits
    PulsePin(PIN_DDS_LATCH); // load serial config bits
    }
    // Write delta phase count to DDS
    // This comes from the integer part of a 64-bit scaled value
    void WriteDDS(uint32_t DeltaPhase) {
    SendRecSPI((byte)DeltaPhase); // low-order byte first
    SendRecSPI((byte)(DeltaPhase >> 8));
    SendRecSPI((byte)(DeltaPhase >> 16));
    SendRecSPI((byte)(DeltaPhase >> 24));
    SendRecSPI(0x00); // 5 MSBs = phase = 0, 3 LSBs must be zero
    PulsePin(PIN_DDS_LATCH); // write data to DDS
    }
    //————–
    // Log amp module
    #define LOG_AMP_SAMPLES 10
    #define LOG_AMP_DELAYMS 10
    uint64_t ReadLogAmp() {
    union ll_u LogAmpRaw;
    LogAmpRaw.fx_64 = 0;
    for (byte i=0; i<LOG_AMP_SAMPLES; i++) {
    LogAmpRaw.fx_32.high += analogRead(PIN_LOG_AMP);
    delay(LOG_AMP_DELAYMS);
    }
    LogAmpRaw.fx_64 /= LOG_AMP_SAMPLES; // figure average from totally ad-hoc number of samples
    LogAmpRaw.fx_64 *= 5; // convert from ADC counts to voltage
    LogAmpRaw.fx_64 /= 1024;
    LogAmpRaw.fx_64 /= 24; // convert from voltage to dBV at 24 mV/dBV
    LogAmpRaw.fx_64 *= 1000;
    return LogAmpRaw.fx_64;
    }
    //———–
    // Scan DDS and record response
    void ScanCrystal() {
    byte ln;
    union ll_u Temp, TestFreq, TestCount;
    XAxisValue = 0;
    PeakdB.fx_64 = 0;
    printf("CX: %s\n",digitalRead(PIN_CX_SHORT) ? "short" : "enable");
    for (ScanFreq = ScanFrom;
    ScanFreq.fx_64 < (ScanTo.fx_64 + ScanStepSize.fx_64 / 2);
    ScanFreq.fx_64 += ScanStepSize.fx_64) {
    digitalWrite(PIN_SYNC,HIGH);
    TestCount.fx_64 = MultiplyFixedPt(ScanFreq,CtPerHz); // compute DDS delta phase
    TestCount.fx_32.low = 0; // truncate count to integer
    TestFreq.fx_64 = MultiplyFixedPt(TestCount,HzPerCt); // compute actual frequency
    Temp.fx_64 = (DAC_MAX * (ScanFreq.fx_64 – ScanFrom.fx_64)); // figure X as fraction
    Temp.fx_64 /= ScanWidth;
    XAxisValue = Temp.fx_32.high;
    digitalWrite(PIN_HEARTBEAT,HIGH);
    WriteDDS(TestCount.fx_32.high); // set DDS to new frequency
    XAxisDAC.setVoltage(XAxisValue,DAC_WR); // and set X axis to match
    digitalWrite(PIN_SYNC,LOW);
    if (ScanFreq.fx_64 == ScanFrom.fx_64) {
    delay(3*ScanSettleMS); // very long settling time
    }
    else {
    delay(ScanSettleMS); // small steps are faster
    }
    LogAmpdB.fx_64 = ReadLogAmp(); // fetch avg value
    if (LogAmpdB.fx_64 > PeakdB.fx_64) { // hit a new high?
    PeakFreq = TestFreq; // save actual frequency
    PeakdB = LogAmpdB;
    ln = digitalRead(PIN_CX_SHORT) ? 4 : 5; // CX selects row
    PrintFixedPtRounded(Buffer,TestFreq,2); // display actual peak
    u8x8.drawString(0,ln,Buffer);
    PrintFixedPtRounded(Buffer,LogAmpdB,1); // tack on response
    u8x8.drawString(16-strlen(Buffer),ln,Buffer);
    }
    ln = 0;
    PrintFixedPtRounded(Buffer,TestFreq,2); // display current frequency
    u8x8.draw2x2String(0,ln++,Buffer);
    ln++; // double-high characters
    printf("%9s ",Buffer); // log to serial port
    PrintFixedPtRounded(Buffer,LogAmpdB,1); // display response
    u8x8.drawString(0,ln,"dBV ");
    u8x8.drawString(16-strlen(Buffer),ln++,Buffer);
    printf(", %6s\n",Buffer); // and log it
    }
    }
    //———–
    // Round scaled fixed point to specific number of decimal places: 0 through 8
    // You should display the value with only Decimals characters beyond the point
    // Must calculate rounding value as separate variable to avoid mystery error
    uint64_t RoundFixedPt(union ll_u TheNumber,unsigned Decimals) {
    union ll_u Rnd;
    Rnd.fx_64 = (ONE_FX >> 1) / (pow(10LL,Decimals)); // that's 0.5 / number of places
    TheNumber.fx_64 = TheNumber.fx_64 + Rnd.fx_64;
    return TheNumber.fx_64;
    }
    //———–
    // Multiply two unsigned scaled fixed point numbers without overflowing a 64 bit value
    // Perforce, the product of the two integer parts mut be < 2^32
    uint64_t MultiplyFixedPt(union ll_u Mcand, union ll_u Mplier) {
    union ll_u Result;
    Result.fx_64 = ((uint64_t)Mcand.fx_32.high * (uint64_t)Mplier.fx_32.high) << 32; // integer parts (clear fract)
    Result.fx_64 += ((uint64_t)Mcand.fx_32.low * (uint64_t)Mplier.fx_32.low) >> 32; // fraction parts (always < 1)
    Result.fx_64 += (uint64_t)Mcand.fx_32.high * (uint64_t)Mplier.fx_32.low; // cross products
    Result.fx_64 += (uint64_t)Mcand.fx_32.low * (uint64_t)Mplier.fx_32.high;
    return Result.fx_64;
    }
    //———–
    // Long long print-to-buffer helpers
    // Assumes little-Endian layout
    void PrintHexLL(char *pBuffer,union ll_u FixedPt) {
    sprintf(pBuffer,"%08lx %08lx",FixedPt.fx_32.high,FixedPt.fx_32.low);
    }
    // converts all 9 decimal digits of fraction, which should suffice
    void PrintFractionLL(char *pBuffer,union ll_u FixedPt) {
    union ll_u Fraction;
    Fraction.fx_64 = FixedPt.fx_32.low; // copy 32 fraction bits, high order = 0
    Fraction.fx_64 *= GIGA; // times 10^9 for conversion
    Fraction.fx_64 >>= 32; // align integer part in low long
    sprintf(pBuffer,"%09lu",Fraction.fx_32.low); // convert low long to decimal
    }
    void PrintIntegerLL(char *pBuffer,union ll_u FixedPt) {
    sprintf(pBuffer,"%lu",FixedPt.fx_32.high);
    }
    void PrintFixedPt(char *pBuffer,union ll_u FixedPt) {
    PrintIntegerLL(pBuffer,FixedPt); // do the integer part
    pBuffer += strlen(pBuffer); // aim pointer beyond integer
    *pBuffer++ = '.'; // drop in the decimal point, tick pointer
    PrintFractionLL(pBuffer,FixedPt);
    }
    void PrintFixedPtRounded(char *pBuffer,union ll_u FixedPt,unsigned Decimals) {
    char *pDecPt;
    FixedPt.fx_64 = RoundFixedPt(FixedPt,Decimals);
    PrintIntegerLL(pBuffer,FixedPt); // do the integer part
    pBuffer += strlen(pBuffer); // aim pointer beyond integer
    pDecPt = pBuffer; // save the point location
    *pBuffer++ = '.'; // drop in the decimal point, tick pointer
    PrintFractionLL(pBuffer,FixedPt); // do the fraction
    if (Decimals == 0)
    *pDecPt = 0; // 0 places means discard the decimal point
    else
    *(pDecPt + Decimals + 1) = 0; // truncate string to leave . and Decimals chars
    }
    //———–
    // Calculate useful "constants" from oscillator info
    // Offset is integer Hz, because 0.1 ppm = 1 Hz at 10 MHz is as close as we can measure
    void CalcOscillator(int32_t Offset) {
    Oscillator.fx_64 = NOMINAL_OSC + ((int64_t)Offset << 32);
    HzPerCt.fx_32.low = Oscillator.fx_32.high; // divide oscillator by 2^32 with simple shifting
    HzPerCt.fx_32.high = 0;
    CtPerHz.fx_64 = -1; // Compute (2^32 – 1) / oscillator
    CtPerHz.fx_64 /= (uint64_t)Oscillator.fx_32.high; // remove 2^32 scale factor from divisor
    }
    //– Helper routine for printf()
    int s_putc(char c, FILE *t) {
    Serial.write(c);
    }
    //———–
    void setup () {
    union ll_u TempFreq,TempCount;
    pinMode(PIN_HEARTBEAT,OUTPUT);
    digitalWrite(PIN_HEARTBEAT,LOW); // show we got here
    pinMode(PIN_SYNC,OUTPUT);
    digitalWrite(PIN_SYNC,LOW);
    Serial.begin (115200);
    fdevopen(&s_putc,0); // set up serial output for printf()
    Serial.println (F("60 kHz Crystal Tester"));
    Serial.println (F("Ed Nisley – KE4ZNU – June 2017\n"));
    // DDS module controls
    pinMode(PIN_DDS_LATCH,OUTPUT);
    digitalWrite(PIN_DDS_LATCH,LOW);
    pinMode(PIN_DDS_RESET,OUTPUT);
    digitalWrite(PIN_DDS_RESET,HIGH);
    // Light up the display
    Serial.println("Initialize OLED");
    u8x8.begin();
    u8x8.setFont(u8x8_font_artossans8_r);
    // u8x8.setPowerSave(0);
    u8x8.setFont(u8x8_font_pxplusibmcga_f);
    u8x8.draw2x2String(0,0,"XtalTest");
    u8x8.drawString(0,3,"Ed Nisley");
    u8x8.drawString(0,4," KE4ZNU");
    u8x8.drawString(0,6,"June 2017");
    // configure SPI hardware
    pinMode(PIN_SS,OUTPUT); // set up manual controls
    digitalWrite(PIN_SS,HIGH);
    pinMode(PIN_SCK,OUTPUT);
    digitalWrite(PIN_SCK,LOW);
    pinMode(PIN_MOSI,OUTPUT);
    digitalWrite(PIN_MOSI,LOW);
    pinMode(PIN_MISO,INPUT_PULLUP);
    SPCR = B00110000; // Auto SPI: no int, disabled, LSB first, master, + edge, leading, f/4
    SPSR = B00000000; // not double data rate
    TogglePin(PIN_HEARTBEAT); // show we got here
    // Set up X axis DAC output
    XAxisDAC.begin(MCP4725_ADDR); // start up MCP4725 DAC at Sparkfun address
    // XAxisDAC.setVoltage(0,DAC_WR_EEP); // do this once per DAC to set power-on at 0 V
    XAxisDAC.setVoltage(0,DAC_WR); // force 0 V after a reset without a power cycle
    // LM75A temperature sensor requires no setup!
    // External capacitor in test fixture
    pinMode(PIN_CX_SHORT,OUTPUT);
    digitalWrite(PIN_CX_SHORT,HIGH); // short = remove external cap
    // Scan limits and suchlike
    ScanFrom.fx_64 = CenterFreq.fx_64 – (ONE_FX * ScanWidth/2);
    ScanTo.fx_64 = CenterFreq.fx_64 + (ONE_FX * ScanWidth/2);
    PrintFixedPtRounded(Buffer,CenterFreq,1);
    printf("Center freq: %s Hz\n",Buffer);
    printf("Settling time: %d ms\n",ScanSettleMS);
    // Wake up and load the DDS
    CalcOscillator(OscOffset); // use default oscillator frequency
    Serial.print("\nStarting DDS: ");
    TempFreq.fx_64 = CALFREQ;
    TempCount.fx_64 = MultiplyFixedPt(TempFreq,CtPerHz);
    // PrintHexLL(Buffer,TempCount);
    // printf(" Count: %s ",Buffer);
    EnableDDS();
    WriteDDS(TempCount.fx_32.high);
    Serial.println("running\n");
    // Zero-beat oscillator to 10 MHz GPS-locked reference
    printf("Zero beat DDS oscillator against GPS\n");
    TempFreq.fx_64 = CALFREQ;
    u8x8.clearDisplay();
    byte ln = 0;
    u8x8.drawString(0,ln++,"10 MHz Zero Beat");
    u8x8.drawString(0,ln++,"<- Joystick ->");
    u8x8.drawString(0,ln++," Button = set ");
    int32_t OldOffset = OscOffset;
    while (analogRead(PIN_JOYBUTTTON) > 500) {
    int ai = analogRead(PIN_JOY_Y) – 512; // totally ad-hoc axes
    if (ai < -100) {
    OscOffset += 1;
    }
    else if (ai > 100) {
    OscOffset -= 1;
    }
    if (OscOffset != OldOffset) {
    ln = 4;
    sprintf(Buffer,"Offset %8ld",OscOffset);
    u8x8.drawString(0,ln++,Buffer);
    CalcOscillator(OscOffset); // recalculate constants
    TempCount.fx_64 = MultiplyFixedPt(TempFreq,CtPerHz); // recalculate delta phase count
    WriteDDS(TempCount.fx_32.high); // should be 10 MHz out!
    OldOffset = OscOffset;
    }
    Wire.requestFrom(LM75_ADDR,2);
    Temperature.fx_32.high = Wire.read();
    Temperature.fx_32.low = (uint32_t)Wire.read() << 24;
    PrintFixedPtRounded(Buffer,Temperature,3);
    ln = 7;
    u8x8.drawString(0,ln,"DDS Temp");
    u8x8.drawString(16-strlen(Buffer),ln++,Buffer);
    delay(100);
    }
    printf("Oscillator offset: %ld\n",OscOffset);
    u8x8.clearDisplay();
    Serial.println("\nStartup done\n");
    MillisThen = millis();
    }
    //———–
    void loop () {
    byte ln;
    union ll_u Temp;
    u8x8.setPowerSave(0);
    u8x8.clearDisplay();
    ln = 0;
    u8x8.draw2x2String(0,2*ln++,"Press");
    u8x8.draw2x2String(0,2*ln++,"Button");
    u8x8.draw2x2String(0,2*ln++,"To Start");
    u8x8.draw2x2String(0,2*ln++,"Test");
    printf("Waiting for button press: ");
    WaitButtonDown();
    printf("\n");
    u8x8.clearDisplay();
    // u8x8.setPowerSave(1);
    // Report temperature
    Wire.requestFrom(LM75_ADDR,2);
    Temperature.fx_32.high = Wire.read();
    Temperature.fx_32.low = (uint32_t)Wire.read() << 24;
    PrintFixedPtRounded(Buffer,Temperature,3);
    printf("Oscillator temperature: %s C\n",Buffer);
    ln = 3;
    u8x8.drawString(0,ln,"DDS Temp");
    u8x8.drawString(16-strlen(Buffer),ln,Buffer);
    // First scan: CX shorted
    digitalWrite(PIN_CX_SHORT,HIGH);
    delay(10);
    ScanCrystal();
    SeriesPeakLow = PeakFreq;
    PrintFixedPtRounded(Buffer,PeakFreq,2); // report peak freq
    printf("\nPeak: %s Hz",Buffer);
    PrintFixedPtRounded(Buffer,PeakdB,1); // tack on response
    printf(" %s dbV\n",Buffer);
    // Second scan: CX in circuit
    digitalWrite(PIN_CX_SHORT,LOW);
    delay(10);
    ScanFrom.fx_64 = SeriesPeakLow.fx_64 – (2 * ONE_FX); // tighten scan limits
    ScanFrom.fx_32.low = 0;
    ScanTo.fx_64 = SeriesPeakLow.fx_64 + (4 * ONE_FX);
    ScanTo.fx_32.low = 0;
    ScanCrystal();
    SeriesPeakHigh = PeakFreq;
    PrintFixedPtRounded(Buffer,PeakFreq,2); // report peak freq
    printf("\nPeak: %s Hz",Buffer);
    PrintFixedPtRounded(Buffer,PeakdB,1); // tack on response
    printf(" %s dbV\n",Buffer);
    ln = 0;
    u8x8.draw2x2String(0,ln," -Done- ");
    ln +=2;
    u8x8.clearLine(ln);
    ln = 6;
    Temp.fx_64 = SeriesPeakHigh.fx_64 – SeriesPeakLow.fx_64;
    PrintFixedPtRounded(Buffer,Temp,2);
    printf("Delta frequency: %s\n",Buffer);
    u8x8.drawString(0,ln,"Delta freq");
    u8x8.drawString(16-strlen(Buffer),ln,Buffer);
    ln = 7;
    u8x8.drawString(0,ln,"Press button …");
    u8x8.setPowerSave(0);
    WaitButtonDown();
    WaitButtonUp();
    }

     

  • Mailing Tube End Caps: Screw-in Version

    The mailing tube arrived with contents intact, although the USPS inlet scanning didn’t work and the tube pretty much teleported across several states without leaving any tracking data behind. The recipient suggested several modifications to the caps:

    Review of user experience of tube end:
    The ribs on the endcap are very good at holding the cap on, so much so that I had to use a prying implement to remove it, which cracked the flange.
    Would consider less depth on the cap, and possibly another layer on the flange.

    Some continuous process improvement (a.k.a OpenSCAD hackage) produced a swoopy threaded cap with thumb-and-finger grips:

    Mailing Tube Screw Cap - top - Slic3r
    Mailing Tube Screw Cap – top – Slic3r

    The finger grips are what’s left after stepping a sphere out of the cap while rotating it around the middle:

    Mailing Tube Cap - finger grip construction
    Mailing Tube Cap – finger grip construction

    That worked out surprisingly well, with the deep end providing enough of a vertical-ish surface to push against.

    The two hex holes fit a pin wrench, because the grips twist only one way: outward. The wrench eliminates the need for a flange, as you can now adjust the cap insertion before slathering packing tape over the ends. Man, I loves me some good late binding action!

    A three-start thread seemed like overkill, but was quick & easy. The “thread form” consists of square rods sunk into the cap perimeter, with one edge sticking out:

    Mailing Tube Cap - thread detail
    Mailing Tube Cap – thread detail

    They’re 1.05 times longer than the cap perimeter facets to make their ends overlap, although they’re not tapered like the ones in the broom handle dingus, because it didn’t (seem to) make any difference to the model’s manifoldhood.

    Not needing any endcaps right now, I built one for show-n-tell:

    Threaded mailing tube end cap - installed
    Threaded mailing tube end cap – installed

    The OpenSCAD source code as a GitHub Gist:

    // Mailing tube end cap
    // Ed Nisley KE4ZNU – June 2017
    Layout = "Build";
    Model = "Screw";
    //- Extrusion parameters – must match reality!
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    Protrusion = 0.1;
    HoleWindage = 0.2;
    //- Screw sizes
    inch = 25.4;
    TubeID = 2 * inch;
    TubeWall = 0.1 * inch;
    CapInsert = 15.0;
    CapRim = 6*ThreadThick;
    CapWall = 3*ThreadWidth;
    NumFlanges = 3;
    FlangeHeight = 3*ThreadThick;
    FlangeWidth = ThreadWidth/2;
    FlangeSpace = CapInsert / (NumFlanges + 1);
    ThumbHoleOD = 20.0;
    ThumbHoleAngle = 100;
    ThumbHoleSteps = 10;
    SpannerPinOD = 5.0;
    HelixOD = 4*ThreadThick;
    HelixHeight = 0.75*CapInsert;
    HelixAngle = atan(HelixHeight/(PI*TubeID));
    HelixStarts = 3;
    OAHeight = CapInsert + CapRim;
    NumRibs = 3*4;
    NumSides = 3*NumRibs;
    //- Adjust hole diameter to make the size come out right
    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);
    }
    module ScrewCap() {
    union() {
    difference() {
    cylinder(d=TubeID,h=OAHeight,$fn=NumSides);
    for (a=[0,180])
    for (i=[0:ThumbHoleSteps-1])
    rotate(a + i*ThumbHoleAngle/ThumbHoleSteps)
    translate([TubeID/4,0,-i*ThumbHoleOD/(2*ThumbHoleSteps)])
    sphere(d=ThumbHoleOD);
    for (a=[0,180])
    rotate(a – 60)
    translate([0.75*TubeID/2,0,-Protrusion])
    rotate(0*180/6)
    PolyCyl(SpannerPinOD,0.75*CapInsert,6);
    }
    for (s=[0:HelixStarts-1])
    for (i=[0:NumSides-1])
    rotate(i*360/NumSides + 180/NumSides + s*360/HelixStarts)
    translate([TubeID/2 – 0.25*HelixOD,0,i*HelixHeight/NumSides + HelixOD])
    rotate([90 + HelixAngle,0,0])
    cylinder(d=HelixOD,h=1.05*PI*TubeID/NumSides,center=true,$fn=4);
    }
    }
    module PushCap() {
    difference() {
    cylinder(d=TubeID,h=OAHeight,$fn=NumSides);
    translate([0,0,CapWall])
    cylinder(d=TubeID – 2*CapWall,h=OAHeight,$fn=NumSides);
    }
    for (i=[1:NumFlanges])
    translate([0,0,i*FlangeSpace])
    difference() {
    cylinder(d=TubeID + 2*FlangeWidth,h=FlangeHeight,$fn=NumSides);
    translate([0,0,-Protrusion])
    cylinder(d=TubeID – 2*CapWall,h=FlangeHeight + 2*Protrusion,$fn=NumSides);
    }
    for (i=[0:NumRibs-1])
    rotate(i*360/NumRibs)
    translate([0,-ThreadWidth,CapWall + ThreadThick])
    cube([TubeID/2 – CapWall/2,2*ThreadWidth,CapInsert + CapRim – CapWall – ThreadThick],center=false);
    translate([0,0,CapInsert]) {
    difference() {
    cylinder(d=TubeID + 2*TubeWall,h=CapRim,$fn=NumSides);
    translate([0,0,-Protrusion])
    cylinder(d=TubeID – 3*2*CapWall,h=2*CapRim,$fn=NumSides);
    }
    }
    }
    //- Build things
    if (Model == "Push")
    if (Layout == "Show")
    PushCap();
    else if (Layout == "Build")
    translate([0,0,OAHeight])
    rotate([180,0,0])
    PushCap();
    if (Model == "Screw")
    if (Layout == "Show")
    ScrewCap();
    else if (Layout == "Build")
    translate([0,0,OAHeight])
    rotate([180,0,0])
    ScrewCap();