This could happen:

It vanished a few hours after appearing at the end of a neighbor’s driveway: a pig must be flying somewhere!
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.
Making the world a better place, one piece at a time

This could happen:

It vanished a few hours after appearing at the end of a neighbor’s driveway: a pig must be flying somewhere!

Having wired a WS2812 RGB LED into my KeyboardIO Atreus, lighting it up requires some QMK firmware configuration. It’s easiest to set up a “new” keymap based on the QMK Atreus files, as described in the QMK startup doc:
qmk new-keymap -kb keyboardio/atreus -km ednisley
Obviously, you’ll pick a different keymap name than I did. All the files mentioned below will reside in the new subdirectory, which starts out with only a keymap.c file copied from the default layout.
The rules.mk file enables RGB Lighting, as well as Auto Shift and Tap Dance:
AUTO_SHIFT_ENABLE = yes # allow automagic shifting
TAP_DANCE_ENABLE = yes # allow multi-tap keys
RGBLIGHT_ENABLE = yes # addressable LEDs
If you had different hardware, you could specify the driver with a WS2812_DRIVER option.
QMK can also control single-color LEDs with PWM (a.k.a. backlighting), and per-key RGB LEDs (a.k.a. RGB Matrix). These functions, their configuration / controls / data, and their documentation overlap and intermingle to the extent that I spent most of my time figuring out what not to include.
Some configuration happens in the config.h file:
#define RGB_DI_PIN B2
#define RGBLED_NUM 1
// https://github.com/qmk/qmk_firmware/blob/master/docs/ws2812_driver.md
//#define WS2812_TRST_US 280
//#define WS2812_BYTE_ORDER WS2812_BYTE_ORDER_GRB
#define RGBLIGHT_LAYERS
#define RGBLIGHT_EFFECT_RGB_TEST
#define RGBLIGHT_LIMIT_VAL 63
#define NO_DEBUG
#define NO_PRINT
The first two lines describe a single WS2812 RGB LED wired to pin B2 (a.k.a. MOSI) of the Atmel 32U4 microcontroller. The default Reset duration and Byte Order values work for the LED I used
Protip: swapping the order from GRB to RGB is a quick way to discover if the firmware actually writes to the LED, even before you get anything else working: it’ll be red with the proper setting and green with the wrong one.
Dialing the maximum intensity down works well with a bright LED shining directly at your face from a foot away.
Turning on RGBLIGHT_LAYERS is what makes this whole thing happen. The RGBLIGHT_EFFECT_RGB_TEST option enables a simple test animation at the cost of a few hundred bytes of code space; remove that line after everything works.
The last two lines remove the debugging facilities; as always with microcontroller projects, there’s enough room for either your code or the debugger required to get it running, but not both.
With those files set up, the keymap.c file does the heavy lifting:
// Modified from the KeyboardIO layout
// Ed Nisley - KE4ZNU
#include QMK_KEYBOARD_H
enum layer_names {
_BASE,
_SHIFTS,
_FUNCS,
_NLAYERS
};
// Tap Dance
enum {
TD_SPC_ENT,
};
qk_tap_dance_action_t tap_dance_actions[] = {
[TD_SPC_ENT] = ACTION_TAP_DANCE_DOUBLE(KC_SPC, KC_ENT),
};
// Layer lighting
// Undefine this to enable simple test mode
// Also put #define RGBLIGHT_EFFECT_RGB_TEST in config.h
#define LED_LL
#ifdef LED_LL
const rgblight_segment_t PROGMEM ll_0[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_WHITE} );
const rgblight_segment_t PROGMEM ll_1[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_MAGENTA} );
const rgblight_segment_t PROGMEM ll_2[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_CYAN} );
const rgblight_segment_t PROGMEM ll_3[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_BLUE} );
const rgblight_segment_t PROGMEM ll_4[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_GREEN} );
const rgblight_segment_t PROGMEM ll_5[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_RED} );
const rgblight_segment_t PROGMEM ll_6[] = RGBLIGHT_LAYER_SEGMENTS( {0,1,HSV_YELLOW} );
const rgblight_segment_t* const PROGMEM ll_layers[] = RGBLIGHT_LAYERS_LIST(
ll_0,ll_1,ll_2,ll_3,ll_4,ll_5,ll_6
);
#endif
void keyboard_post_init_user(void) {
#ifdef LED_LL
rgblight_layers = ll_layers;
rgblight_set_layer_state(0, 1);
#else
rgblight_enable_noeeprom();
rgblight_mode_noeeprom(RGBLIGHT_MODE_RGB_TEST);
// rgblight_mode_noeeprom(RGBLIGHT_MODE_BREATHING + 3);
#endif
}
#ifdef LED_LL
layer_state_t layer_state_set_user(layer_state_t state) {
for (uint8_t i=0 ; i < _NLAYERS; i++)
rgblight_set_layer_state(i, layer_state_cmp(state, i));
return state;
}
#endif
// Key maps
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
[_BASE] = LAYOUT( // base layer for typing
KC_Q, KC_W, KC_E, KC_R, KC_T, KC_Y, KC_U, KC_I, KC_O, KC_P ,
KC_A, KC_S, KC_D, KC_F, KC_G, KC_H, KC_J, KC_K, KC_L, KC_SCLN ,
KC_Z, KC_X, KC_C, KC_V, KC_B, KC_GRV, KC_LALT, KC_N, KC_M, KC_COMM, KC_DOT, KC_SLSH ,
LT(_FUNCS,KC_ESC), KC_TAB, KC_LGUI, KC_BSPC, KC_LSFT, KC_LCTL, KC_ENT , TD(TD_SPC_ENT), MO(_SHIFTS), KC_MINS, KC_QUOT, KC_BSLS),
[_SHIFTS] = LAYOUT( // shifted chars and numpad
KC_EXLM, KC_AT, KC_UP, KC_DLR, KC_PERC, KC_PGUP, KC_7, KC_8, KC_9, KC_HOME,
KC_LPRN, KC_LEFT, KC_DOWN, KC_RGHT, KC_RPRN, KC_PGDN, KC_4, KC_5, KC_6, KC_END,
KC_LBRC, KC_RBRC, KC_HASH, KC_LCBR, KC_RCBR, KC_CIRC, KC_AMPR,KC_ASTR, KC_1, KC_2, KC_3, KC_PLUS,
KC_NO , KC_INS, KC_LGUI, KC_DEL , KC_BSPC, KC_LCTL, KC_LALT,KC_SPC, KC_TRNS, KC_DOT, KC_0, KC_EQL ),
[_FUNCS] = LAYOUT( // function keys
KC_INS, KC_HOME, KC_UP, KC_END, KC_PGUP, KC_UP, KC_F7, KC_F8, KC_F9, KC_F10 ,
KC_DEL, KC_LEFT, KC_DOWN, KC_RGHT, KC_PGDN, KC_DOWN, KC_F4, KC_F5, KC_F6, KC_F11 ,
KC_NO, KC_VOLU, KC_NO, KC_NO, RESET, _______, _______, KC_NO, KC_F1, KC_F2, KC_F3, KC_F12 ,
KC_NO, KC_VOLD, KC_LGUI, KC_LSFT, KC_BSPC, KC_LCTL, KC_LALT, KC_SPC, TO(_BASE), KC_PSCR, KC_SLCK, KC_PAUS )
};
Undefine LED_LL to enable the test mode, compile, flash, and the LED should cycle red / green / blue forever; you also need the RGB_TEST option in the config.h file.
Define LED_LL and layer lighting should then Just Work™, with the LED glowing:
The key map code defines colors for layers that don’t yet exist, but it should get you started.
For convenience, I wadded all three QMK files into a GitHub Gist.
The LED is kinda subtle:

As you might expect, figuring all that out took much longer than for you to read about it, but now I have a chance of remembering what I did.

Having scouted out the territory inside the KeyboardIO Atreus, adding an LED requires taking it completely apart to drill a hole in the aluminum faceplate:

Reattaching the plate to the PCB with only three screws allows marking the hole position on the PCB, which is much easier than pretending to derive the position from first principles:

Despite appearances, I traced the hole with a mechanical pencil: black graphite turns shiny silvery gray against matte black soldermask. Also, the PCB trace is off-center, not the hole.
Overlay the neighborhood with Kapton tape to protect the PCB from what comes next:

Snip a WS2812 RGB LED from a strip, stick it in place with eyeballometric alignment over the target, and wire it up:

Despite the terrible reliability of WS2812 RGB LEDs mounted on PCB carriers, a different set on a meter of high-density flex tape have worked reasonably well when not thermally stressed, so I’ll assume this one arrived in good order.
Aligning the LED directly under the hole required a few iterations:

The iridescent green patch is a diffraction pattern from the controller chip’s internal circuitry.
The data comes from MOSI, otherwise known as B2, down in the lower left corner:

Actually lighting the LED now becomes a simple matter of software QMK firmware.

Although I was blithely unaware when I bought some useful-looking surplus, it turns out 1/16 inch armature wire works really well to seal our homebrew masks around our noses. Mary added a narrow passage along the top edge of her slightly reshaped Fu Mask pattern to retain the wire and I provided 4.5 inch lengths of straightened wire:

The wire comes off the roll in dead-soft condition, so I can straighten (and slightly harden) it by simply rolling each wire with eight fingertips across the battered cutting board. The slightly wavy wire shows its as-cut condition and the three straight ones are ready for their masks.
Although nearly pure aluminum wire doesn’t work-harden quickly, half a year of mask duty definitely takes its toll. This sample came from my biking mask after the edges wore out:

We initially thought using two wires would provide a better fit, but more metal just made adjusting the nose seal more difficult after each washing. The wire has work-hardened enough to make the sharper bends pretty much permanent; they can be further bent, but no longer roll out under finger pressure.
Although we’re not yet at the point where we must reuse wires, I took this as an opportunity to improve my annealing hand: heat the wire almost to its melting point, hold it there for a few seconds, then let it cool slowly. The usual technique involves covering the aluminum with something like hand soap or permanent marker ink, heat until the soap / marker burns away, then let it air-cool. Unlike steel, there’s no need for quenching or tempering.
Blue Sharpie worked surprisingly well with a propane torch:

As far as I can tell after a few attempts, the pigment vanishes just below the annealing temperature and requires another pass to reach the right temperature. Sweep the flame steadily, don’t pause, and don’t hold the wire over anything melt-able.
Those wires (I cut the doubled wire apart) aren’t quite as soft as the original stock, but they rolled straight and are certainly good enough for our simple needs; they’re back in the Basement Laboratory Warehouse for future (re)use.

After replacing the M2’s nozzle, I also installed a spare filament drive:

That’s the V4 R3 version, although I bought it from Makergear rather than fight with all the support required to get a proper bearing opening.
The long M4 screw and spring apply a constant force to the filament against the drive gear, rather than the constant position from the default (and much shorter) stock screw. The lever arm does have some springiness, but not much travel, so IMO the spring works better with the fine teeth in the drive gear.
This drive has a 5 mm hole at the top for the stock PTFE guide tube, which I long ago replaced with ¼ inch OD HDPE tubing to reduce the friction required to get the filament off the spool and into the hot end. The rather hideous hot-melt glue blob holding a ¼ inch ID tube onto the previous drive never failed enough to bother me, but a little lathe action produced a much better adapter:

It’s a chunk of ⅜ inch = 9.5 mm Delrin rod with a 2.4 mm hole through that 5 mm spigot for easy extraction of a gear-mashed 1.75 mm filament. The other end has a 6.5 mm hole drilled 20 mm deep to hold the guide tube.
Looks downright dressy, it does!

The bent-steel brake levers on our Drive Blue Streak wheelchair present themselves edge-on to the rider:

There are good mechanical reasons for shaping and orienting the steel like that, but the handle concentrates the considerable force required to push the brake tab into the rubberoid tire on your (well, my) palms. After a couple of weeks, I decided I didn’t need two more sore spots and conjured a palm-filling knob from the vasty digital deep:

Bonus part: the little octagon near the wheel prevents the leg rest (seen in the first picture) from smashing into the end of the brake tab and chipping the lovely blue powder coat. The brown fuzzy felt foot seemed like a good idea at the time, but isn’t strictly necessary.
A cylindrical handle on Thingiverse apparently fits on the bare steel underneath the rubberish “cushion”, but cutting a perfectly good, albeit uncomfortable, cushion off seemed like a step in the wrong direction. My knob thus descends from a doodle of the OEM dimensions:

The knob builds in two halves adjoining the bonus octagon, which stands on edge to eliminate support inside its slot:

You (probably) need two of all those shapes, a job your slicer is ready to perform. At three hours for each knob, I just printed the same G-Code twice.
You can customize the knob width to fit your palm, with the other two dimensions fitting themselves around the cushion. Mary and I settled on a knob size that fits both our hands reasonably well, so it’s probably not critical.
I tried building the knob halves without support for the first prototype, but the sloped upper surface produced awful bridging:

It’s easy enough to design a customized support structure:

I oriented the knob to put the split on the narrow sides of the brake handle in order to not have a seam facing my palm:

The quartet of M3×20 mm socket-head cap screws thread into brass inserts epoxied into the rear half. I recessed their heads deeply into the front half and avoided thinking too hard about plugs matching the surface curvature:

The low-vertex-count polygonal shape is a stylin’ thing and produces a nice feel during a firm shove, at least to my paws. Although I’d rather not need a wheelchair at all, setting the brakes now seems authoritative instead of annoying.
The OpenSCAD source code as a GitHub gist:
| // Pride wheelchair brake lever mods | |
| // Ed Nisley KE4ZNU 2020-11 | |
| /* [Layout options] */ | |
| Layout = "Build"; // [Build, Show, Fit, TabCap, Handle, Knob, Support] | |
| // Hold up the knob's inside | |
| Support = true; | |
| /* [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; | |
| //———————- | |
| // 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); | |
| } | |
| //* [Basic dimensions] */ | |
| WallThick = 4.0; // min wall thickness | |
| Screw = [3.0,5.5,20.0]; // thread, head, length under head | |
| Insert = [3.0,4.1,8.0]; // thread, knurl, length | |
| //———————- | |
| // Brake tab cap | |
| BrakeTab = [15,21,3.1]; // length to wheel, width, thickness | |
| BrakeTabSagitta = 8.0; // height of curved endcap | |
| CapOAL = [BrakeTab.y + 2*WallThick,BrakeTab.y + 2*WallThick,BrakeTab.z + 2*WallThick]; | |
| module TabCap() { | |
| difference() { | |
| rotate(180/8) | |
| cylinder(d=CapOAL.y,h=CapOAL.z,center=true,$fn=8); | |
| translate([BrakeTab.x/2,0,0]) | |
| cube(BrakeTab,center=true); | |
| rotate(180/8) | |
| cylinder(d=BrakeTab.y/cos(180/8),h=BrakeTab.z,center=true,$fn=8); | |
| } | |
| } | |
| //———————- | |
| // Brake lever handle | |
| // Soft covering with rounded sides that we square off for simplicity | |
| HandleRibs = [15,34,14]; // ignoring slight taper from end | |
| HandleCore = [50.0,24.0,12.0]; // straight section of lever to top of ribs | |
| HandleTipWidth = 30.0; // ignoring actual sector height | |
| module Handle() { | |
| union() { | |
| hull() { | |
| rotate(180/8) | |
| cylinder(d=HandleTipWidth/cos(180/8),h=HandleCore.z,center=true,$fn=8); | |
| translate([-HandleCore.x/2,0,0]) | |
| cube(HandleCore,center=true); | |
| } | |
| translate([-(3*HandleCore.x/2 – Protrusion),0,0]) // extend base for ball trimming | |
| cube(HandleCore,center=true); | |
| translate([-HandleRibs.x/2,0,0]) | |
| cube(HandleRibs,center=true); | |
| } | |
| } | |
| //———————- | |
| // Support structure for handle cavity inside knob | |
| // Totally ad-hoc tweakage | |
| // Remember it's lying on its side to match the handle | |
| NumRibs = 2 + 1; // must be odd | |
| RibSpace = floor(HandleCore.z/(NumRibs + 1)); | |
| module KnobSupport() { | |
| color("Yellow") { // support overlaps in the middle | |
| render(convexity=3) | |
| intersection() { | |
| for (k=[-1,1]) | |
| translate([0,k*ThreadThick,0]) // shrink inward to break adhesion | |
| Handle(); | |
| translate([(HandleCore.x – HandleRibs.x)/2 – HandleCore.x – Protrusion,0,0]) | |
| cube([HandleCore.x – HandleRibs.x,HandleRibs.y,HandleCore.z],center=true); | |
| union() | |
| for (k=[-floor(NumRibs/2):floor(NumRibs/2)]) | |
| translate([0,0,k* RibSpace]) | |
| cube([2*HandleCore.x,HandleRibs.y,2*ThreadWidth],center=true); | |
| } | |
| translate([(HandleCore.x – HandleRibs.x)/2 – HandleCore.x,0,0]) | |
| cube([HandleCore.x – HandleRibs.x,4*ThreadWidth,NumRibs*RibSpace],center=true); | |
| } | |
| } | |
| //———————- | |
| // Brake handle knob | |
| // Largely built with magic numbers | |
| // Includes support because it's not really optional | |
| KnobOD = 55.0; | |
| KnobOffset = HandleRibs.x/1; | |
| KnobSides = 2*4*3; | |
| module Knob() { | |
| difference() { | |
| hull() { | |
| resize([0,HandleRibs.y + 4*WallThick,HandleCore.x + HandleTipWidth/2 + WallThick]) | |
| sphere(d=KnobOD,$fn=KnobSides); | |
| } | |
| translate([0,0,KnobOffset]) | |
| rotate([0,-90,0]) | |
| Handle(); | |
| for (i=[-1,1],k=[-1,1]) | |
| translate([i*KnobOD/4,0,k*KnobOD/4]) { | |
| rotate([90,0,0]) | |
| PolyCyl(Insert[OD],1.5*Insert[LENGTH],6); | |
| translate([0,-Screw[LENGTH]/2,0]) | |
| rotate([-90,0,0]) | |
| PolyCyl(Screw[ID],KnobOD,6); | |
| translate([0,Screw[LENGTH] – Insert[LENGTH],0]) | |
| rotate([-90,0,0]) | |
| PolyCyl(Screw[OD],KnobOD,6); | |
| } | |
| } | |
| if (Support) | |
| translate([0,0,KnobOffset]) | |
| rotate([0,-90,0]) | |
| KnobSupport(); | |
| } | |
| //———————- | |
| // Lash it together | |
| if (Layout == "TabCap") { | |
| TabCap(); | |
| } | |
| if (Layout == "Handle") { | |
| Handle(); | |
| } | |
| if (Layout == "Support") { | |
| KnobSupport(); | |
| } | |
| if (Layout == "Knob") { | |
| Knob(); | |
| } | |
| if (Layout == "Show") { | |
| translate([60,0,0]) | |
| TabCap(); | |
| Knob(); | |
| } | |
| if (Layout == "Fit") { | |
| translate([60,0,0]) | |
| difference() { | |
| TabCap(); | |
| translate([0,0,CapOAL.z/2]) | |
| cube(CapOAL,center=true); | |
| } | |
| difference() { | |
| Knob(); | |
| translate([KnobOD + KnobOD/4,0*KnobOD,0]) | |
| cube(2*KnobOD,center=true); | |
| translate([-KnobOD,-KnobOD,0]) | |
| cube(2*KnobOD,center=true); | |
| } | |
| } | |
| if (Layout == "Build") { | |
| translate([KnobOD/2,0,(CapOAL.y*cos(180/8))/2]) | |
| rotate([0,-90,90]) | |
| TabCap(); | |
| for (j=[-1,1]) | |
| translate([0,-j*0.75*HandleCore.x,0]) | |
| difference() { | |
| rotate([j*90,0,0]) | |
| Knob(); | |
| translate([0,0,-KnobOD]) | |
| cube(2*KnobOD,center=true); | |
| } | |
| } |
A doodle with dimensions of other parts:

The angled tab on the middle left is for the leg rest release latch, but I decided not to silk-purse-ize the thing.
Posts will appear intermittently over the next week or two.
I’m still spending an inordinate amount of time studying the back of my eyelids while horizontally polarized in the lift chair. I can highly recommend not doing whatever it is that triggers a pinched lumbar nerve, but as nearly as I can tell, the proximate cause (shredding leaves) isn’t anything close to whatever the root cause might be.
It does provide plenty of time to conjure solid models from the vasty digital deep:

The wheelchair brake lever seems to have been designed by somebody who never actually had to shove it very often:

At least I can fix that …