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.

Tag: Improvements

Making the world a better place, one piece at a time

  • Raspberry Pi Rotary Encoder: Knob Switch Key

    Raspberry Pi Rotary Encoder: Knob Switch Key

    The rotary encoder knob I’m using for these tests has a pushbutton switch in its shaft:

    RPi rotary encoder - improved test fixture
    RPi rotary encoder – improved test fixture

    Now that I know where to look, it turns out there’s a Raspberry Pi overlay for that:

    Name:   gpio-key
    Info:   This is a generic overlay for activating GPIO keypresses using
            the gpio-keys library and this dtoverlay. Multiple keys can be
            set up using multiple calls to the overlay for configuring
            additional buttons or joysticks. You can see available keycodes
            at https://github.com/torvalds/linux/blob/v4.12/include/uapi/
            linux/input-event-codes.h#L64
    Load:   dtoverlay=gpio-key,<param>=<val>
    Params: gpio                    GPIO pin to trigger on (default 3)
            active_low              When this is 1 (active low), a falling
                                    edge generates a key down event and a
                                    rising edge generates a key up event.
                                    When this is 0 (active high), this is
                                    reversed. The default is 1 (active low)
            gpio_pull               Desired pull-up/down state (off, down, up)
                                    Default is "up". Note that the default pin
                                    (GPIO3) has an external pullup
            label                   Set a label for the key
            keycode                 Set the key code for the button
    
    

    Snuggle the button configuration next to the encoder in /boot/config.txt:

    dtoverlay=rotary-encoder,pin_a=20,pin_b=21,relative_axis=1,steps-per-period=2<br>dtoverlay=gpio-key,gpio=26,keycode=83,label="KNOB"
    

    I haven’t yet discovered where the label text appears, because I picked a keycode defining the button as the decimal point key on a numeric keypad. Perhaps one could create a unique key from whole cloth, but that’s in the nature of fine tuning. In any event, pressing / releasing the button produces key-down / key-up events just like you’d get from a real keyboard.

    The four pins required for the encoder + switch make a tidy block at the right (in this view, left as shown above) end of the RPi’s header:

    Raspberry Pi pinout
    Raspberry Pi pinout

    If you needed the SPI1 hardware, you’d pick different pins.

    Reboot that sucker and another input device appears:

    ll /dev/input/by-path/
    total 0
    lrwxrwxrwx 1 root root 9 Oct 18 10:00 platform-button@1a-event -> ../event0
    lrwxrwxrwx 1 root root 9 Oct 18 10:00 platform-rotary@14-event -> ../event2
    lrwxrwxrwx 1 root root 9 Oct 18 10:00 platform-soc:shutdown_button-event -> ../event1

    As with the encoder device, the button device name includes the hex equivalent of the pin number: 26 decimal = 0x1a.

    Run some code:

    # Keypress from Raspberry Pi GPIO pin using evdev
    # Add to /boot/config.txt
    #  dtoverlay=gpio-key,gpio=26,keycode=83,label="KNOB"
    
    import evdev
    
    b = evdev.InputDevice('/dev/input/by-path/platform-button@1a-event')
    print('Button device: {}'.format(b.name))
    
    print(' caps: {}'.format(b.capabilities(verbose=True)))
    print(' fd: {}'.format(b.fd))
    
    for e in b.read_loop():
        print('Event: {}'.format(e))
        if e.type == evdev.ecodes.EV_KEY:
            print('Key {}: {}'.format(e.code,e.value))
    
    

    Which produces this output:

    Button device: button@1a
    caps: {('EV_SYN', 0): [('SYN_REPORT', 0), ('SYN_CONFIG', 1)], ('EV_KEY', 1): [('KEY_KPDOT', 83)]}
    fd: 3
    Event: event at 1603036309.683348, code 83, type 01, val 01
    Key 83: 1
    Event: event at 1603036309.683348, code 00, type 00, val 00
    Event: event at 1603036310.003329, code 83, type 01, val 00
    Key 83: 0
    Event: event at 1603036310.003329, code 00, type 00, val 00

    All in all, that was easy …

  • Raspberry Pi Rotary Encoder: evdev Proof of Concept

    Raspberry Pi Rotary Encoder: evdev Proof of Concept

    After Bill Wittig pointed me in the right direction, writing a Python program to correctly read a rotary encoder knob on a Raspberry Pi is straightforward. At least given some hints revealed by knowing the proper keywords.

    First, enhance the knob’s survivability & usability by sticking it on a perfboard scrap:

    RPi rotary encoder - improved test fixture
    RPi rotary encoder – improved test fixture

    Then find the doc in /boot/overlays/README:

    Name: rotary-encoder
    Info: Overlay for GPIO connected rotary encoder.
    Load: dtoverlay=rotary-encoder,
    =
    Params: pin_a GPIO connected to rotary encoder channel A
    (default 4).
    pin_b GPIO connected to rotary encoder channel B
    (default 17).
    relative_axis register a relative axis rather than an
    absolute one. Relative axis will only
    generate +1/-1 events on the input device,
    hence no steps need to be passed.
    linux_axis the input subsystem axis to map to this
    rotary encoder. Defaults to 0 (ABS_X / REL_X)
    rollover Automatic rollover when the rotary value
    becomes greater than the specified steps or
    smaller than 0. For absolute axis only.
    steps-per-period Number of steps (stable states) per period.
    The values have the following meaning:
    1: Full-period mode (default)
    2: Half-period mode
    4: Quarter-period mode
    steps Number of steps in a full turnaround of the
    encoder. Only relevant for absolute axis.
    Defaults to 24 which is a typical value for
    such devices.
    wakeup Boolean, rotary encoder can wake up the
    system.
    encoding String, the method used to encode steps.
    Supported are "gray" (the default and more
    common) and "binary".

    Add a line to /boot/config.txt to configure the hardware:

    dtoverlay=rotary-encoder,pin_a=20,pin_b=21,relative_axis=1,steps-per-period=2

    The overlay enables the pullup resistors by default, so the encoder just pulls the pins to common. Swapping the pins reverses the sign of the increments, which may be easier than swapping the connector after you have it all wired up.

    The steps-per-period matches the encoder in hand, which has 30 detents per full turn; the default value of 1 step/period resulted in every other detent doing nothing. A relative axis produces increments of +1 and -1, rather than the accumulated value useful for an absolute encoder with hard physical stops.

    Reboot that sucker and an event device pops up:

    ll /dev/input
    total 0
    drwxr-xr-x 2 root root 80 Oct 18 07:46 by-path
    crw-rw---- 1 root input 13, 64 Oct 18 07:46 event0
    crw-rw---- 1 root input 13, 65 Oct 18 07:46 event1
    crw-rw---- 1 root input 13, 63 Oct 18 07:46 mice

    I’m unable to find the udev rule (or whatever) creating those aliases and, as with all udev trickery, the device’s numeric suffix is not deterministic. The only way you (well, I) can tell which device is the encoder and which is the power-off button is through their aliases:

    ll /dev/input/by-path/
    total 0
    lrwxrwxrwx 1 root root 9 Oct 18 07:46 platform-rotary@14-event -> ../event0
    lrwxrwxrwx 1 root root 9 Oct 18 07:46 platform-soc:shutdown_button-event -> ../event1

    The X axis of the mice device might report the same values, but calling a rotary encoder a mouse seems fraught with technical debt.

    The name uses the hex equivalent of the A channel pin number (20 = 0x14), so swapping the pins in the configuration will change the device name; rewiring the connector may be easier.

    Using the alias means you always get the correct device:

    # Rotary encoder using evdev
    # Add to /boot/config.txt
    #  dtoverlay=rotary-encoder,pin_a=20,pin_b=21,relative_axis=1,steps-per-period=2
    # Tweak pins and steps to match the encoder
    
    import evdev
    
    d = evdev.InputDevice('/dev/input/by-path/platform-rotary@14-event')
    print('Rotary encoder device: {}'.format(d.name))
    
    position = 0
    
    for e in d.read_loop():
        print('Event: {}'.format(e))
        if e.type == evdev.ecodes.EV_REL:
            position += e.value
            print('Position: {}'.format(position))
    

    Which should produce output along these lines:

    Rotary encoder device: rotary@14
    Event: event at 1603019654.750255, code 00, type 02, val 01
    Position: 1
    Event: event at 1603019654.750255, code 00, type 00, val 00
    Event: event at 1603019654.806492, code 00, type 02, val 01
    Position: 2
    Event: event at 1603019654.806492, code 00, type 00, val 00
    Event: event at 1603019654.949199, code 00, type 02, val 01
    Position: 3
    Event: event at 1603019654.949199, code 00, type 00, val 00
    Event: event at 1603019655.423506, code 00, type 02, val -1
    Position: 2
    Event: event at 1603019655.423506, code 00, type 00, val 00
    Event: event at 1603019655.493140, code 00, type 02, val -1
    Position: 1
    Event: event at 1603019655.493140, code 00, type 00, val 00
    Event: event at 1603019655.624685, code 00, type 02, val -1
    Position: 0
    Event: event at 1603019655.624685, code 00, type 00, val 00
    Event: event at 1603019657.652883, code 00, type 02, val -1
    Position: -1
    Event: event at 1603019657.652883, code 00, type 00, val 00
    Event: event at 1603019657.718956, code 00, type 02, val -1
    Position: -2
    Event: event at 1603019657.718956, code 00, type 00, val 00
    Event: event at 1603019657.880569, code 00, type 02, val -1
    Position: -3
    Event: event at 1603019657.880569, code 00, type 00, val 00
    

    The type 00 events are synchronization points, which might be more useful with more complex devices.

    Because the events happen outside the kernel scheduler’s notice, you (well, I) can now spin the knob as fast as possible and the machinery will generate one increment per transition, so the accumulated position changes smoothly.

    Much better!

  • Arducam Motorized Focus Camera: Focusing Equation

    Arducam Motorized Focus Camera: Focusing Equation

    The values written to the I²C register controlling the Arducam Motorized Focus Camera lens position are strongly nonlinear with distance, so a simple linear increment / decrement isn’t particularly useful. If one had an equation for the focus value given the distance, one could step linearly by distance.

    So, we begin.

    Set up a lens focus test range amid the benchtop clutter with found objects marking distances:

    Arducam Motorized Focus camera - test setup
    Arducam Motorized Focus camera – test setup

    Fire up the video loopback arrangement to see through the camera:

    Arducam Motorized Focus test - focus infinity
    Arducam Motorized Focus test – focus infinity

    The camera defaults to a focus at infinity (or, perhaps, a bit beyond), corresponding to 0 in its I²C DAC (or whatever). The blue-green scenery visible through the window over on the right is as crisp as it’ll get through a 5 MP camera, the HP spectrum analyzer is slightly defocused at 80 cm, and everything closer is fuzzy.

    Experimentally, the low byte of the I²C word written to the DAC doesn’t change the focus much at all, so what you see below comes from writing a focus value to the high byte and zero to the low byte.

    For example, to write 18 (decimal) to the camera:

    i2cset -y 0 0x0c 18 0

    That’s I²C bus 0 (through the RPi camera ribbon cable), camera lens controller address 0x0c (you could use 12 decimal), focus value 18 * 256 + 0 = 0x12 + 0x00 = 4608 decimal.

    Which yanks the focus inward to 30 cm, near the end of the ruler:

    Arducam Motorized Focus test - focus 30 cm
    Arducam Motorized Focus test – focus 30 cm

    The window is now blurry, the analyzer becomes better focused, and the screws at the far end of the yellow ruler look good. Obviously, the depth of field spans quite a range at that distance, but iterating a few values at each distance gives a good idea of the center point.

    A Bash one-liner steps the focus inward from infinity while you arrange those doodads on the ruler:

    for i in {0..31} ; do let h=i*2 ; echo "high: " $h ; let rc=1 ; until (( rc < 1 )) ; do i2cset -y 0 0x0c $h 0 ; let rc=$? ; echo "rc: " $rc ; done ; sleep 1 ; done

    Write 33 to set the focus at 10 cm:

    Arducam Motorized Focus test - focus 10 cm
    Arducam Motorized Focus test – focus 10 cm

    Then write 55 for 5 cm:

    Arducam Motorized Focus test - focus 5 cm
    Arducam Motorized Focus test – focus 5 cm

    The tick marks show the depth of field might be 10 mm.

    Although the camera doesn’t have a “thin lens” in the optical sense, for my simple purposes the ideal thin lens equation gives some idea of what’s happening. I think the DAC value moves the lens more-or-less linearly with respect to the sensor, so it should be more-or-less inversely related to the focus distance.

    Take a few data points, reciprocate & scale, plot on a doodle pad:

    Arducam Motorized Focus RPi Camera - focus equation doodles
    Arducam Motorized Focus RPi Camera – focus equation doodles

    Dang, I loves me some good straight-as-a-ruler plotting action!

    The hook at the upper right covers the last few millimeters of lens travel where the object distance is comparable to the sensor distance, so I’ll give the curve a pass.

    Feed the points into a calculator and curve-fit to get an equation you could publish:

    DAC MSB = 10.8 + 218 / (distance in cm)
    = 10.8 + 2180 / distance in mm)

    Given the rather casual test setup, the straight-line section definitely doesn’t support three significant figures for the slope and we could quibble about exactly where the focus origin sits with respect to the camera.

    So this seems close enough:

    DAC MSB = 11 + 2200 / (distance in mm)

    Anyhow, I can now tweak a “distance” value in a linear-ish manner (perhaps with a knob, but through evdev), run the equation, send the corresponding DAC value to the camera lens controller, and have the focus come out pretty close to where it should be.

    Now, to renew my acquaintance with evdev

  • RPi HQ Camera: 4.8 mm Computar Video Lens

    RPi HQ Camera: 4.8 mm Computar Video Lens

    The Big Box o’ Optics disgorged an ancient new-in-box Computar 4.8 mm lens, originally intended for a TV camera, with a C mount perfectly suited for the Raspberry Pi HQ camera:

    RPi HQ Camera - Computar 4.8mm - front view
    RPi HQ Camera – Computar 4.8mm – front view

    Because it’s a video lens, it includes an aperture driver expecting a video signal from the camera through a standard connector:

    Computar 4.8 mm lens - camera plug
    Computar 4.8 mm lens – camera plug

    The datasheet tucked into the box (!) says it expects 8 to 16 V DC on the red wire (with black common) and video on white:

    Computar Auto Iris TV Lens Manual
    Computar Auto Iris TV Lens Manual

    Fortunately, applying 5 V to red and leaving white unconnected opens the aperture all the way. Presumably, the circuitry thinks it’s looking at a really dark scene and isn’t fussy about the missing sync pulses.

    Rather than attempt to find / harvest a matching camera connector, the cord now terminates in a JST plug, with the matching socket hot-melt glued to the Raspberry Pi case:

    RPi HQ Camera - 4.8 mm Computar lens - JST power
    RPi HQ Camera – 4.8 mm Computar lens – JST power

    The Pi has +5 V and ground on the rightmost end of its connector, so the Computar lens will be jammed fully open.

    I gave it something to look at:

    RPi HQ Camera - Computar 4.8mm - overview
    RPi HQ Camera – Computar 4.8mm – overview

    With the orange back plate about 150 mm from the RPi, the 4.8 mm lens delivers this scene:

    RPi HQ Camera - 4.8 mm Computar lens - 150mm near view
    RPi HQ Camera – 4.8 mm Computar lens – 150mm near view

    The focus is on the shutdown / startup button just to the right of the heatsink, so the depth of field is maybe 25 mm front-to-back.

    For comparison, the official 16 mm lens stopped down to f/8 has a tighter view with good depth of field:

    RPi HQ Camera - 16 mm lens - 150mm near view
    RPi HQ Camera – 16 mm lens – 150mm near view

    It’d be nice to have a variable aperture, but it’s probably not worth the effort.

  • Magnetic Base: Last 10% Manufacturing

    Magnetic Base: Last 10% Manufacturing

    A magnetic base of unknown provenance and surprising expense when bought new emerged from the back of the workbench:

    Erick Magna Holder - side view
    Erick Magna Holder – side view

    It’s been hiding back there since the first (attempted) use showed it wasn’t a quadruped:

    Erick Magna Holder - as-delivered stance
    Erick Magna Holder – as-delivered stance

    Grabbing the other end in the bench vise and whacking the top of the offending leg with a brass persuader pretty much lined it up. Closer inspection showed a problem with the push-to-detach lever:

    Erick Magna Holder - rivet pivot
    Erick Magna Holder – rivet pivot

    The rivet head and thin washers extend a bit beyond the circular arc, with the rivet holding the leg above whatever it’s supposed to stick to. I think the scarring on the rivet was an attempt to improve the situation, perhaps during a QC adjustment session, that didn’t quite work.

    The hole through the leg is a touch under 4 mm and the Big Box o’ Random Small Screws disgorged a 6-32 screw with what might have been a 5/32 inch = 4 mm nominal = 3.8 mm actual shoulder of exactly the right length:

    Erick Magna Holder - 6-32 screw clearance
    Erick Magna Holder – 6-32 screw clearance

    The screw head flange cleared the floor, but wasn’t much of an improvement over the rivet. I eventually chucked it in the lathe and removed the flange & hex-head corners, an improvement you won’t see here.

    Even with the frame whacked into alignment, all four feet didn’t contact the surface plate along their entire lengths. Absent a surface grinder, I deployed a big blue Sharpie and the largest file on hand:

    Erick Magna Holder - filing base
    Erick Magna Holder – filing base

    Iterating Sharpie and file eventually knocked off enough of the high spots to make it Good Enough™ for the intended purpose, which is definitely not precision metrology:

    Erick Magna Holder - bottom filed
    Erick Magna Holder – bottom filed

    Those chunky cross-pieces are Old School alnico magnets, which is the only reason a simple lever can pry it off a steel plate.

    Now, at least, it can stand on its own four feet.

    As Johnny Mnemonic put it: “These days … you have to be pretty technical before you can even aspire to crudeness“.

  • Kenmore Progressive Vacuum Cleaner vs. Dust Brush Adapters

    Kenmore Progressive Vacuum Cleaner vs. Dust Brush Adapters

    Contemporary vacuum cleaner dust brush heads have bristles in some combination of [long | short] with [flexy | stiff]. The long + flexy combination results in the bristles jamming the inlet and the short + stiff combo seems unsuited for complex surfaces. Shaking the Amazonian dice brought a different combination:

    Vacuum cleaner dust brush assortment - with adapters
    Vacuum cleaner dust brush assortment – with adapters

    That’s the new one on the bottom and, contrary to what you might think from the picture, it is not identical to the one just above it.

    In particular, the black plastic housing came from a different mold (the seam lines are now top-and-bottom) and required a new adapter for the Kenmore Progressive vacuum cleaner’s complicated wand / hose inlet, with a 3/4 inch PVC pipe reinforcement inside.

    Early reports indicate it works fine, so I’ll declare a temporary victory in the war on entropy.

    I’m still using the same OpenSCAD source code with minute tweaks to suit the as-measured tapers.

  • Raspberry Pi HQ Camera Mount

    Raspberry Pi HQ Camera Mount

    As far as I can tell, Raspberry Pi cases are a solved problem, so 3D printing an intricate widget to stick a Pi on the back of an HQ camera seems unnecessary unless you really, really like solid modeling, which, admittedly, can be a thing. All you really need is a simple adapter between the camera PCB and the case of your choice:

    HQ Camera Backplate - OpenSCAD model
    HQ Camera Backplate – OpenSCAD model

    A quartet of 6 mm M2.5 nylon spacers mount the adapter to the camera PCB:

    RPi HQ Camera - nylon standoffs
    RPi HQ Camera – nylon standoffs

    The plate has recesses to put the screw heads below the surface. I used nylon screws, but it doesn’t really matter.

    The case has all the right openings, slots in the bottom for a pair of screws, and costs six bucks. A pair of M3 brass inserts epoxied into the plate capture the screws:

    RPi HQ Camera - case adapter plate - screws
    RPi HQ Camera – case adapter plate – screws

    Thick washers punched from an old credit card go under the screws to compensate for the case’s silicone bump feet. I suppose Doing the Right Thing would involve 3D printed spacers matching the cross-shaped case cutouts.

    Not everyone agrees with my choice of retina-burn orange PETG:

    RPi HQ Camera - 16 mm lens - case adapter plate
    RPi HQ Camera – 16 mm lens – case adapter plate

    Yes, that’s a C-mount TV lens lurking in the background, about which more later.

    The OpenSCAD source code as a GitHub Gist:

    // Raspberry Pi HQ Camera Backplate
    // Ed Nisley KE4ZNU 2020-09
    //– Extrusion parameters
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    function IntegerLessMultiple(Size,Unit) = Unit * floor(Size / Unit);
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    ID = 0;
    OD = 1;
    LENGTH = 2;
    //- Basic dimensions
    CamPCB = [39.0,39.0,1.5]; // Overall PCB size, plus a bit
    CornerRound = 3.0; // … has rounded corners
    CamScrewOC = [30.0,30.0,0]; // … mounting screw layout
    CamScrew = [2.5,5.0,2.2]; // … LENGTH = head thickness
    Standoff = [2.5,5.5,6.0]; // nylon standoffs
    Insert = [3.0,4.0,4.0];
    WallThick = IntegerMultiple(2.0,ThreadWidth);
    PlateThick = Insert[LENGTH];
    CamBox = [CamPCB.x + 2*WallThick,
    CamPCB.y + 2*WallThick,
    Standoff.z + PlateThick + CamPCB.z + 1.0];
    PiPlate = [90.0,60.0,PlateThick];
    PiPlateOffset = [0.0,(PiPlate.y – CamBox.y)/2,0];
    PiSlotOC = [0.0,40.0];
    PiSlotOffset = [3.5,3.5];
    NumSides = 2*3*4;
    TextDepth = 2*ThreadThick;
    //———————-
    // 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);
    }
    //———————-
    // Build it
    difference() {
    union() {
    hull() // camera enclosure
    for (i=[-1,1], j=[-1,1])
    translate([i*(CamBox.x/2 – CornerRound),j*(CamBox.y/2 – CornerRound),0])
    cylinder(r=CornerRound,h=CamBox.z,$fn=NumSides);
    translate(PiPlateOffset)
    hull()
    for (i=[-1,1], j=[-1,1]) // Pi case plate
    translate([i*(PiPlate.x/2 – CornerRound),j*(PiPlate.y/2 – CornerRound),0])
    cylinder(r=CornerRound,h=PiPlate.z,$fn=NumSides);
    }
    hull() // camera PCB space
    for (i=[-1,1], j=[-1,1])
    translate([i*(CamPCB.x/2 – CornerRound),j*(CamPCB.y/2 – CornerRound),PlateThick])
    cylinder(r=CornerRound,h=CamBox.z,$fn=NumSides);
    translate([0,-CamBox.y/2,PlateThick + CamBox.z/2])
    cube([CamScrewOC.x – Standoff[OD],CamBox.y,CamBox.z],center=true);
    for (i=[-1,1], j=[-1,1]) // camera screws with head recesses
    translate([i*CamScrewOC.x/2,j*CamScrewOC.y/2,-Protrusion]) {
    PolyCyl(CamScrew[ID],2*CamBox.z,6);
    PolyCyl(CamScrew[OD],CamScrew[LENGTH] + Protrusion,6);
    }
    for (j=[-1,1]) // Pi case screw inserts
    translate([0,j*PiSlotOC.y/2 + PiSlotOffset.y,-Protrusion] + PiPlateOffset)
    PolyCyl(Insert[OD],2*PiPlate.z,6);
    translate([-PiPlate.x/2 + (PiPlate.x – CamBox.x)/4,0,PlateThick – TextDepth/2] + PiPlateOffset)
    cube([15.0,30.0,TextDepth + Protrusion],center=true);
    }
    translate([-PiPlate.x/2 + (PiPlate.x – CamBox.x)/4 + 3,0,PlateThick – TextDepth – Protrusion] + PiPlateOffset)
    linear_extrude(height=TextDepth + Protrusion,convexity=2)
    rotate(-90)
    text("Ed Nisley",font="Arial:style=Bold",halign="center",valign="center",size=4,spacing=1.05);
    translate([-PiPlate.x/2 + (PiPlate.x – CamBox.x)/4 – 3,0,PlateThick – TextDepth – Protrusion] + PiPlateOffset)
    linear_extrude(height=TextDepth + Protrusion,convexity=2)
    rotate(-90)
    text("KE4ZNU",font="Arial:style=Bold",halign="center",valign="center",size=4,spacing=1.05);