Alpha Geek Clock: Radome Update

There being nothing like a new problem to take one’s mind off all one’s old problems:

C-Max CMMR-60 WWVB receiver - D cell display holder
C-Max CMMR-60 WWVB receiver – D cell display holder

It’s a variation on the camera battery and AA alkaline holders for various blinky LEDs:

Astable Multivibrator - D cell WWVB
Astable Multivibrator – D cell WWVB

The little flag holding the C-Max CMMR-60 receiver PCB gets glued to the copper upright to keep it from swiveling in the breeze.

The conical caps on the ferrite bar antenna are glued to the uprights and the antenna, in the expectation this is a one-off build-only project.

Rather than buy specialized D-cell contacts, I used 18650 lithium cell contacts and conjured the bridge by soldering two together:

D cell bridge contact from 18650 contacts
D cell bridge contact from 18650 contacts

It sits on the windowsill, blinks quietly in the dark, and flickers invisibly during the daytime.

Those D cells came from the same batch that powered the previous version for the last five years, so they probably won’t last that long, even with a Nov 2024 date code.

C-Max is apparently out of the WWVB biz, but you can get a similar Canaduino AM WWVB receiver.

The far more complex EverSet ES100-MOD WWVB receiver requires a microcontroller with an I²C interface and very careful power management.

The OpenSCAD source code as a GitHub Gist:

// Astable Multivibrator
// Holder for Alkaline cells
// Ed Nisley KE4ZNU August 2020
// 2020-09 add LED radome
// 2020-11 add radome trim
// 2021-11 D cells and WWVB receiver
/* [Layout options] */
Layout = "Build"; // [Build,Show,Lid,Spider,AntCap,RecFlag]
CellName = "AA"; // [AA, D]
Struts = -1; // [0:None, -1:Dual, 1:Quad]
WWVB = true;
/* [Hidden] */
NumCells = 2; // [2]
// 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;
//- Basic dimensions
WallThick = IntegerMultiple(3.0,ThreadWidth);
CornerRadius = WallThick/2;
FloorThick = IntegerMultiple(3.0,ThreadThick);
TopThick = IntegerMultiple(2.0,ThreadThick);
WireOD = 1.5; // battery & LED wiring
WireOC = 8.0; // hole spacing in lid
Gap = 5.0;
// Cylindrical cell sizes
// https://en.wikipedia.org/wiki/List_of_battery_sizes#Cylindrical_batteries
CELL_NAME = 0;
CELL_OD = 1;
CELL_OAL = 2;
// FIXME search() needs special-casing to properly find AAA and AAAA
// Which is why CellName is limited to AA
CellData = [
["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], // bare 18650 with button end
["18650Prot",19.0,70.0], // protected 18650 = 19670 plus a bit
];
CellIndex = search([CellName],CellData,1,0)[0];
echo(str("Cell index: ",CellIndex," = ",CellData[CellIndex][CELL_NAME]));
//- Contact dimensions
CONTACT_NAME = 0;
CONTACT_WIDE = 1;
CONTACT_HIGH = 2;
CONTACT_THICK = 3; // plate thickness
CONTACT_TIP = 4; // tip to rear face
CONTACT_TAB = 5; // solder tab width
ContactData = [
["AA+",12.2,12.2,0.3,1.7,3.5], // pos bump
["AA-",12.2,12.2,0.3,5.0,3.5], // half-compressed neg spring
["AA+-",28.2,12.2,0.3,5.0,0], // pos-neg bridge
["D+",18.5,16.0,0.3,2.8,5.5],
["D-",18.5,16.0,0.3,6.0,5.5],
["D+-",50.0,19.0,0.3,7.0,0], // solder +/- tabs together
["Li+",18.5,16.0,0.3,2.8,5.5],
["Li-",18.5,16.0,0.3,6.0,5.5],
];
function ConDat(name,dim) = ContactData[search([name],ContactData,1,0)[0]][dim];
ContactRecess = 2*ConDat(str(CellName,"+"),CONTACT_THICK);
ContactOC = CellData[CellIndex][CELL_OD];
WireBay = 6.0; // room for wiring to contacts
//- Wire struts
StrutDia = 1.6; // AWG 14 = 1.6 mm
StrutSides = 3*4;
ID = 0;
OD = 1;
LENGTH = 2;
StrutBase = [StrutDia,StrutDia + 2*5*ThreadWidth, // ID = wire, OD = buildable
FloorThick + CellData[CellIndex][CELL_OD]]; // LENGTH = base is flush with cell top
//- Holder dimensions
BatterySize = [CellData[CellIndex][CELL_OAL] + // cell
ConDat(str(CellName,"+"),CONTACT_TIP) + // pos contact
ConDat(str(CellName,"-"),CONTACT_TIP) - // neg contact
2*ContactRecess, // sink into wall
NumCells*CellData[CellIndex][CELL_OD],
CellData[CellIndex][CELL_OD]
];
echo(str("Battery space: ",BatterySize));
CaseSize = [3*WallThick + // end walls + wiring partition
BatterySize.x + // cell
WireBay, // wiring bay
2*WallThick + BatterySize.y,
FloorThick + BatterySize.z
];
echo(str("CaseSize: ",CaseSize));
BatteryOffset = (CaseSize.x - (2*WallThick +
CellData[CellIndex][CELL_OAL] +
ConDat(str(CellName,"-"),CONTACT_TIP))
) /2 ;
ThumbRadius = 0.75 * CaseSize.z;
StrutOC = [IntegerLessMultiple(CaseSize.x - 2*CornerRadius -2*StrutBase[OD],5.0),
IntegerMultiple(CaseSize.y + StrutBase[OD],5.0)];
StrutAngle = atan(StrutOC.y/StrutOC.x);
echo(str("Strut OC: ",StrutOC));
LidSize = [2*WallThick + WireBay + ConDat(str(CellName,"+"),CONTACT_THICK), CaseSize.y, FloorThick/2];
LidScrew = [2.0,3.8,7.0]; // M2 pan head screw (LENGTH = threaded)
LidScrewOC = CaseSize.y/2 - CornerRadius - LidScrew[OD]; // allow space around screw head
//- Piranha LEDs
PiranhaBody = [8.0,8.0,8.0]; // Z = heatsink fins + plastic body + lens
PiranhaPin = 0.0; // trimmed pin length beyond heatsink
PiranhaPinsOC = [5.0,5.0]; // pin XY distance
PiranhaRecess = PiranhaBody.z + PiranhaPin/2; // minimum LED recess depth
BallOD = 40.0; // radome sphere
BallSides = 4*3*4; // nice smoothness
PillarOD = norm([PiranhaBody.x,PiranhaBody.y]) + 2*WallThick;
BallChordM = BallOD/2 - sqrt(pow(BallOD/2,2) - (pow(PillarOD,2))/4);
echo(str("Ball chord depth: ",BallChordM));
RadomePillar = [norm([PiranhaBody.x,PiranhaBody.y]), // ID = LED diagonal
PillarOD,
FloorThick + PiranhaRecess + BallChordM]; // height to top of ball chord
echo(str("Pillar: ",RadomePillar));
RadomeBar = [StrutBase[OD]*cos(180/StrutSides),StrutOC.y,StrutBase[OD]/2];
Tape = [RadomePillar[ID],16.0,1.0]; // sticky tape disk, OD to match hole punch
//- WWVB receiver hardware
Antenna = [10.0 + 0.5,14.0,60.0 + 2.0]; // ferrite antenna bar with clearance
AntCapSize = [Antenna[ID] + 1.0,Antenna[OD],5.0]; // LENGTH=insertion
RecPCB = [24.0,16.0,5.0];
//----------------------
// 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);
}
// Spider for single LED atop struts, with the ball
module DualSpider() {
difference() {
union() {
for (j=[-1,1]) {
for (k=[-1,1])
translate([0,j*StrutOC.y/2,k*RadomeBar.z])
rotate(180/StrutSides)
sphere(d=StrutBase[OD]/cos(180/StrutSides),$fn=StrutSides);
translate([0,j*StrutOC.y/2,0])
rotate(180/StrutSides)
cylinder(d=StrutBase[OD],h=2*RadomeBar.z,center=true,$fn=StrutSides);
}
cube(RadomeBar,center=true); // connecting bar
cylinder(d=RadomePillar[OD],h=RadomePillar[LENGTH],$fn=BallSides);
translate([0,0,-RadomeBar.z/2])
cylinder(d1=0.9*RadomePillar[OD],d2=RadomePillar[OD],h=RadomeBar.z/2,$fn=BallSides);
}
for (j=[-1,1]) // strut wires
translate([0,j*StrutOC.y/2,-3*StrutBase[OD]/2])
rotate(180/StrutSides)
PolyCyl(StrutBase[ID],2*StrutBase[OD],StrutSides);
for (k=[-1,1]) // LED wiring through bar
translate([0,k*(StrutOC.x/2 - 2*RadomeBar.x),-RadomeBar.z])
rotate(180/6)
PolyCyl(StrutBase[ID],2*RadomeBar.z,6);
translate([0,0,BallOD/2 + RadomePillar[LENGTH] - BallChordM]) // ball inset
sphere(d=BallOD);
translate([0,0,BallOD/2 + RadomePillar[LENGTH] - BallChordM - Tape[LENGTH]/2]) // tape inset
intersection() {
sphere(d=BallOD);
cylinder(d=Tape[OD],h=2*BallOD,center=true);
}
translate([0,0,RadomePillar.z - PiranhaRecess + RadomePillar.z/2]) // LED inset
cube(PiranhaBody + [HoleWindage,HoleWindage,RadomePillar.z],center=true); // XY clearance
translate([0,0,StrutBase[OD]/4 + WireOD/2 + 0*Protrusion]) // wire channels
cube([WireOD,RadomePillar[OD] + 2*WallThick,WireOD],center=true);
}
}
//-- WWVB antenna support cap
module AntennaBar() {
rotate([90,0,0])
union() {
cylinder(d=Antenna[ID],h=Antenna[LENGTH],$fn=BallSides,center=true);
cylinder(d=2*Antenna[OD],h=Antenna[LENGTH] - 2*AntCapSize[LENGTH],$fn=BallSides,center=true);
}
}
module AntennaCap() {
rotate([90,0,0])
intersection() {
translate([0,-Antenna[LENGTH]/2 + AntCapSize[LENGTH],0])
difference() {
hull() {
rotate([90,0,0])
cylinder(d=AntCapSize[OD],h=Antenna[LENGTH],$fn=BallSides,center=true);
for (j=[-1,1])
translate([0,j*StrutOC.y/2,0])
rotate(180/StrutSides)
cylinder(d=StrutBase[OD],h=1*StrutBase[OD],$fn=StrutSides,center=true);
}
for (j=[-1,1])
translate([0,j*StrutOC.y/2,-Antenna[OD]/2])
rotate(180/StrutSides)
PolyCyl(StrutBase[ID],Antenna[OD],StrutSides);
AntennaBar();
}
rotate([-90,0,0])
cylinder(d=Antenna[OD],h=Antenna[LENGTH],center=false);
}
}
//-- WWVB PCB support flag
module RecFlag() {
difference() {
hull() {
rotate(180/StrutSides)
cylinder(d=StrutBase[OD],h=RecPCB.x,$fn=StrutSides);
translate([0,RecPCB.y,0])
rotate(180/StrutSides)
cylinder(d=StrutBase[OD],h=RecPCB.x,$fn=StrutSides);
}
translate([0,0,-Protrusion])
rotate(180/StrutSides)
PolyCyl(StrutBase[ID],2*RecPCB.x,StrutSides);
translate([0,StrutBase[OD]/2,-Protrusion])
cube([StrutBase[OD],RecPCB.y,2*RecPCB.x],center=false);
}
}
//-- Overall case with origin at battery center
module Case() {
union() {
difference() {
union() {
hull()
for (i=[-1,1], j=[-1,1])
translate([i*(CaseSize.x/2 - CornerRadius),
j*(CaseSize.y/2 - CornerRadius),
0])
cylinder(r=CornerRadius/cos(180/8),h=CaseSize.z,$fn=8); // cos() fixes undersize spheres!
if (Struts)
for (i = (Struts == 1) ? [-1,1] : -1) { // strut bases
hull()
for (j=[-1,1])
translate([i*StrutOC.x/2,j*StrutOC.y/2,0])
rotate(180/StrutSides)
cylinder(d=StrutBase[OD],h=StrutBase[LENGTH],$fn=StrutSides);
translate([i*StrutOC.x/2,0,StrutBase[LENGTH]/2])
cube([2*StrutBase[OD],StrutOC.y,StrutBase[LENGTH]],center=true); // blocks for fairing
for (j=[-1,1]) // hemisphere caps
translate([i*StrutOC.x/2,
j*StrutOC.y/2,
StrutBase[LENGTH]])
rotate(180/StrutSides)
sphere(d=StrutBase[OD]/cos(180/StrutSides),$fn=StrutSides);
}
}
translate([BatteryOffset,0,BatterySize.z/2 + FloorThick]) // cells
cube(BatterySize + [0,0,Protrusion],center=true);
translate([BatterySize.x/2 + BatteryOffset + ContactRecess/2 - Protrusion/2, // contacts
0,
BatterySize.z/2 + FloorThick])
cube([ContactRecess + Protrusion,
ConDat(str(CellName,"+-"),CONTACT_WIDE),
ConDat(str(CellName,"+-"),CONTACT_HIGH)
],center=true);
translate([-(BatterySize.x/2 - BatteryOffset + ContactRecess/2 - Protrusion/2),
ContactOC/2,
BatterySize.z/2 + FloorThick])
cube([ContactRecess + Protrusion,
ConDat(str(CellName,"+"),CONTACT_WIDE),
ConDat(str(CellName,"+"),CONTACT_HIGH)
],center=true);
translate([-(BatterySize.x/2 - BatteryOffset + ContactRecess/2 - Protrusion/2),
-ContactOC/2,
BatterySize.z/2 + FloorThick])
cube([ContactRecess + Protrusion,
ConDat(str(CellName,"-"),CONTACT_WIDE),
ConDat(str(CellName,"-"),CONTACT_HIGH)
],center=true);
translate([-CaseSize.x/2 + WireBay/2 + WallThick, // wire bay with screw bosses
0,
BatterySize.z/2 + FloorThick + Protrusion/2])
cube([WireBay,
2*LidScrewOC - LidScrew[ID] - 2*4*ThreadWidth,
BatterySize.z + Protrusion
],center=true);
for (j=[-1,1]) // screw holes
translate([-CaseSize.x/2 + WireBay/2 + WallThick,
j*LidScrewOC,
CaseSize.z - LidScrew[LENGTH] + Protrusion])
PolyCyl(LidScrew[ID],LidScrew[LENGTH],6);
for (j=[-1,1])
translate([-(BatterySize.x/2 - BatteryOffset + WallThick/2), // contact tabs
j*ContactOC/2,
BatterySize.z + FloorThick - Protrusion])
cube([2*WallThick,
ConDat(str(CellName,"+"),CONTACT_TAB),
(BatterySize.z - ConDat(str(CellName,"+"),CONTACT_HIGH))
],center=true);
if (false)
translate([0,0,CaseSize.z]) // finger cutout
rotate([90,00,0])
cylinder(r=ThumbRadius,h=2*CaseSize.y,center=true,$fn=22);
if (Struts)
for (i2 = (Struts == 1) ? [-1,1] : -1) { // strut wire holes and fairing
for (j=[-1,1])
translate([i2*StrutOC.x/2,j*StrutOC.y/2,FloorThick])
rotate(180/StrutSides)
PolyCyl(StrutBase[ID],2*StrutBase[LENGTH],StrutSides);
for (i=[-1,1], j=[-1,1]) // fairing cutaways
translate([i*StrutBase[OD] + (i2*StrutOC.x/2),
j*StrutOC.y/2,
-Protrusion])
rotate(180/StrutSides)
PolyCyl(StrutBase[OD],StrutBase[LENGTH] + 2*Protrusion,StrutSides);
}
translate([0,0,ThreadThick - Protrusion]) // recess around name
cube([51.0,15,2*ThreadThick],center=true);
}
linear_extrude(height=2*ThreadThick + Protrusion,convexity=10) {
translate([0,-3.5,0])
mirror([0,1,0])
text(text="softsolder",size=6,spacing=1.20,font="Arial:style:Bold",halign="center",valign="center");
translate([0,3.5,0])
mirror([0,1,0])
text(text=".com",size=6,spacing=1.20,font="Arial:style:Bold",halign="center",valign="center");
}
}
}
module Lid() {
difference() {
hull()
for (i=[-1,1], j=[-1,1], k=[-1,1])
translate([i*(LidSize.x/2 - CornerRadius),
j*(LidSize.y/2 - CornerRadius),
k*(LidSize.z - CornerRadius)]) // double thickness for flat bottom
sphere(r=CornerRadius/cos(180/8),$fn=8);
translate([0,0,-LidSize.z]) // remove bottom
cube([(LidSize.x + 2*Protrusion),(LidSize.y + 2*Protrusion),2*LidSize.z],center=true);
for (j=[-1,1]) // wire holes
translate([0,j*WireOC/2,-Protrusion])
PolyCyl(WireOD,2*LidSize.z,6);
for (j=[-1,1])
translate([0,j*LidScrewOC,-Protrusion])
PolyCyl(LidScrew[ID],2*LidSize.z,6);
}
}
//-------------------
// Show & build stuff
if (Layout == "Case")
Case();
if (Layout == "Lid")
Lid();
if (Layout == "AntCap")
AntennaCap();
if (Layout == "RecFlag")
RecFlag();
if (Layout == "Spider")
if (Struts == -1)
DualSpider();
else
cube(10,center=true);
if (Layout == "Build") {
rotate(90)
Case();
translate([0,-(CaseSize.x/2 + LidSize.x/2 + Gap),0])
rotate(90)
Lid();
if (Struts == -1) {
difference() {
union() {
translate([CaseSize.x/2 + RadomePillar[OD],0,0])
DualSpider();
translate([-(CaseSize.x/2 + RadomePillar[OD]),0,0])
rotate([180,0,0])
DualSpider();
}
translate([0,0,-2*CaseSize.z])
rotate(90)
cube(4*CaseSize,center=true);
}
}
if (WWVB) {
for (i=[-1,1])
translate([i*(Antenna[LENGTH]/2 - AntCapSize[LENGTH]),CaseSize.x/2 + Antenna[OD],0])
AntennaCap();
translate([0,CaseSize.x/2 + Antenna[OD],0])
RecFlag();
}
}
if (Layout == "Show") {
Case();
for (j=[-1,1])
color("Brown",0.3)
translate([-StrutOC.x/2,j*StrutOC.y/2,Protrusion])
cylinder(d=StrutDia[ID],h=3*CaseSize.z,$fn=StrutSides);
translate([-(CaseSize.x/2 - LidSize.x/2),0,(CaseSize.z + Gap)])
Lid();
if (Struts == -1)
translate([-StrutOC.x/2,0,3*CaseSize.z])
DualSpider();
if (WWVB) {
for (j=[-1,1])
translate([-StrutOC.x/2,,j*(Antenna[LENGTH]/2 - AntCapSize[LENGTH]),1.5*CaseSize.z])
rotate([-j*90,0,0])
AntennaCap();
translate([-StrutOC.x/2,,-(StrutOC.y/2),2*CaseSize.z])
RecFlag();
}
}

Dirt Devil Vacuum Tool Adapters

Being the domain expert for adapters between a new vacuum cleaner and old tools, this made sense (even though it’s not our vacuum):

Dirt Devil Nozzle Bushing - solid model
Dirt Devil Nozzle Bushing – solid model

The notch snaps into a Dirt Devil Power Stick vacuum cleaner and the tapered end fits a variety of old tools for other vacuum cleaners:

Dirt Devil Nozzle Bushing top view - solid model
Dirt Devil Nozzle Bushing top view – solid model

Having some experience breaking thin-walled adapters, these have reinforcement from a PVC tube:

Dirt Devil adapter - parts
Dirt Devil adapter – parts

A smear of epoxy around the interior holds the tube in place:

Dirt Devil adapters - assembled
Dirt Devil adapters – assembled

Building the critical dimensions with a 3D printed part simplified the project, because I could (and did!) tweak the OpenSCAD code to match the tapers to the tools. Turning four of those tubes from a chunk of PVC conduit, however, makes a story for another day.

The OpenSCAD source code as a GitHub Gist:

// Dirt Devil nozzle adapter
// Ed Nisley KE4ZNU 2021-10
// Tool taper shift
Finesse = -0.1; // [-0.5:0.1:0.5]
// PVC pipe liner
PipeOD = 28.5;
/* [Hidden] */
//- Extrusion parameters
ThreadThick = 0.25;
ThreadWidth = 0.40;
HoleWindage = 0.2;
function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
Protrusion = 0.1; // make holes end cleanly
//----------------------
// Dimensions
TAPER_MIN = 0;
TAPER_MAX = 1;
TAPER_LENGTH = 2;
Socket = [36.0,37.0,40.0];
LockringDia = 33.5;
LockringWidth = 4.5;
LockringOffset = 2.5;
Tool = [Finesse,Finesse,0] + [30.0,31.1,30.0];
AdapterOAL = Socket[TAPER_LENGTH] + Tool[TAPER_LENGTH];
NumSides = 36;
$fn = NumSides;
//----------------------
// 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);
}
//-------------------
// Define it!
module Adapter() {
difference() {
union() {
difference() {
cylinder(d1=Socket[TAPER_MIN],d2=Socket[TAPER_MAX],h=Socket[TAPER_LENGTH]);
translate([0,0,LockringOffset])
cylinder(d=2*Socket[TAPER_MAX],h=LockringWidth);
}
cylinder(d=LockringDia,h=Socket[TAPER_LENGTH]);
translate([0,0,LockringOffset + 0.75*LockringWidth])
cylinder(d1=LockringDia,d2=Socket[TAPER_MIN],h=0.25*LockringWidth);
translate([0,0,Socket[TAPER_LENGTH]])
cylinder(d1=Tool[TAPER_MAX],d2=Tool[TAPER_MIN],h=Tool[TAPER_LENGTH]);
}
translate([0,0,-Protrusion])
PolyCyl(PipeOD,AdapterOAL + 2*Protrusion,NumSides);
}
}
//----------------------
// Build it!
Adapter();

The taper in the code almost certainly won’t fit whatever tool you have: measure thrice, print twice, and maybe fit once …

Tour Easy Rear Running Light: Circuit Support Plate

Building the circuit support plate for the amber front running light was entirely too fiddly:

1 W LED Running Light - baseplate dry assembly
1 W LED Running Light – baseplate dry assembly

This was definitely easier:

Running Light Circuit Plate - solid model
Running Light Circuit Plate – solid model

Two pins fit in the small holes to align it with the LED heatsink, with an M3 stud and brass insert holding it in place:

Tour Easy Rear Running Light - circuit plate attachment
Tour Easy Rear Running Light – circuit plate attachment

The rectangular hole around the insert let me glop urethane adhesive over it to lock it into the plate, with more goop on the screw and pins to unify heatsink and plate.

The LED wires now emerge from the heatsink on the same side of the plate, simplifying the connections to the MP1584 regulator and current-sense resistor:

Tour Easy Rear Running Light - regulator wiring
Tour Easy Rear Running Light – regulator wiring

The paralleled 5.1 Ω and 3.3 Ω resistors form a 2.0 Ω resistor setting the LED current to 400 mA = 1 W at 2.6 V forward drop. They’re 1 W resistors dissipating a total of 320 mW and get barely warm.

The resistors and wires are stuck in place with clear adhesive, so things shouldn’t rattle around too much.

The OpenSCAD source code as a GitHub Gist:

// Circuit plate for Tour Easy running lights
// Ed Nisley - KE4ZNU - 2021-09
/* [Hidden] */
ThreadThick = 0.25;
ThreadWidth = 0.40;
HoleWindage = 0.2;
Protrusion = 0.1; // make holes end cleanly
function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
ID = 0;
OD = 1;
LENGTH = 2;
inch = 25.4;
//----------------------
// Dimensions
// Light case along X axis
LightID = 23.0;
WallThick = 2.0;
Screw = [3.0,6.8,4.0]; // M3 OD=washer, length=nut + washers
Insert = [3.0,4.2,8.0]; // splined brass insert, minus splines
InsertOffset = 10.0; // insert from heatsink end
PinOD = 1.6; // alignment pins
PinOC = 14.0;
PinDepth = 5.0;
Plate = [50.0,LightID,Insert[OD] + 4*ThreadThick]; // overall plate size
WirePort = [10.0,3.0,2*Plate.z];
NumSides = 2*3*4;
//----------------------
// Useful routines
module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
FixDia = Dia / cos(180/Sides);
cylinder(r=(FixDia + HoleWindage)/2,
h=Height,
$fn=Sides);
}
// Circuit plate
module Plate() {
difference() {
intersection() {
cube(Plate,center=true);
rotate([0,90,0])
cylinder(d=LightID,h=2*Plate.x,$fn=NumSides,center=true);
}
rotate([0,90,0]) rotate(180/6)
translate([0,0,-Plate.x])
PolyCyl(Screw[ID],2*Plate.x,6);
rotate([0,90,0]) rotate(180/6)
translate([0,0,-Plate.x/2 - Protrusion])
PolyCyl(Insert[OD],Insert[LENGTH] + InsertOffset + Protrusion,6);
translate([-Plate.x/2 + InsertOffset + Insert[LENGTH]/2,0,Plate.z/2])
cube([Insert[LENGTH],Insert[OD],Plate.z],center=true);
for (j=[-1,1])
translate([-Plate.x/2,j*PinOC/2,0])
rotate([0,90,0]) rotate(180/6)
translate([0,0,-PinDepth])
PolyCyl(PinOD,2*PinDepth,6);
for (j=[-1,1])
translate([0,j*(Plate.y/2 - WirePort.y/2),0])
cube(WirePort,center=true);
}
}
//- Build it
Plate();

Rear Running Light: Tour Easy Seat Clamp

With the amber front running light blinking away, it’s time to replace the decade-old Planet Bike Superflash behind the seat:

Superflash on Tour Easy
Superflash on Tour Easy

The new mount descends directly from the clamps holding the fairing strut on the handlebars and various hose clamps:

Rear Running Light Seat Clamp - solid model
Rear Running Light Seat Clamp – solid model

The central block has two quartets of brass inserts epoxied inside:

Rear Running Light Seat Clamp - sectioned - solid model
Rear Running Light Seat Clamp – sectioned – solid model

That means I can install the light, then mount the whole affair on the bike, without holding everything together while fiddling with overly long screws.

A trial fit with the not-yet-cut-to-length 25.3 (-ish) PVC pipe body tube:

Rear Running Light - Tour Easy seat clamp trial fit
Rear Running Light – Tour Easy seat clamp trial fit

The aluminum plates have the standard used-car finish: nice polish over deep scratches.

Although I’ve been thinking of mounting the light below the seat rail, as shown, it can also sit above the rail.

Mary hauls seedlings and suchlike to the garden in a plastic drawer bungied to the rack, with the SuperFlash serving as an anchor point; this light may need fine tuning for that purpose.

The OpenSCAD source code as a GitHub Gist:

// Rear running light clamp for Tour Easy seat strut
// Ed Nisley - KE4ZNU - 2021-09
Layout = "Show"; // [Show,Build,Block]
Section = true;
/* [Hidden] */
ThreadThick = 0.25;
ThreadWidth = 0.40;
HoleWindage = 0.2;
Protrusion = 0.1; // make holes end cleanly
function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
ID = 0;
OD = 1;
LENGTH = 2;
inch = 25.4;
//----------------------
// Dimensions
// Light case along X axis, seat strut along Y, Z=0 at strut centerline
LightOD = 25.4 + HoleWindage;
StrutOD = 5/8 * inch + HoleWindage;
PlateThick = 1/16 * inch;
WallThick = 2.0;
Kerf = ThreadThick;
Screw = [3.0,6.8,4.0]; // M3 OD=washer, length=nut + washers
Insert = [3.0,5.4,8.0 + 1.0]; // splined brass insert
RoundRadius = IntegerMultiple(Screw[OD]/2,0.5); // corner rounding
ScrewOC = [IntegerMultiple(StrutOD + 2*WallThick + Screw[ID],1.0),
IntegerMultiple(LightOD + 2*WallThick + Screw[ID],1.0)];
echo(str("Screw OC: ",ScrewOC));
BlockSize = [ScrewOC.x + Insert[OD] + 2*WallThick,
ScrewOC.y + Insert[OD] + 2*WallThick,
LightOD + StrutOD + 3*WallThick];
echo(str("Block: ",BlockSize));
BaseOffset = -(WallThick + LightOD/2); // block bottom to centerline
StrutOffset = LightOD/2 + WallThick + StrutOD/2; // light centerline to strut centerline
echo(str("Strut screw min: ",IntegerMultiple(PlateThick + WallThick + StrutOD/2 + Insert[LENGTH]/2,1.0)));
echo(str("Light screw min: ",IntegerMultiple(PlateThick + WallThick + LightOD/2 + Insert[LENGTH]/2,1.0)));
NumSides = 2*3*4;
//----------------------
// Useful routines
module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
FixDia = Dia / cos(180/Sides);
cylinder(r=(FixDia + HoleWindage)/2,
h=Height,
$fn=Sides);
}
// Block with light along X axis
module Block() {
difference() {
hull()
for (i=[-1,1], j=[-1,1])
translate([i*(BlockSize.x/2 - RoundRadius),j*(BlockSize.y/2 - RoundRadius),BaseOffset])
cylinder(r=RoundRadius,h=BlockSize.z,$fn=NumSides);
for (i=[-1,1], j=[-1,1])
translate([i*ScrewOC.x/2,j*ScrewOC.y/2,BaseOffset - Protrusion])
rotate(180/8)
PolyCyl(Screw[ID],BlockSize.z + 2*Protrusion,8);
for (i=[-1,1], j=[-1,1])
translate([i*ScrewOC.x/2,j*ScrewOC.y/2,0]) {
translate([0,0,-Protrusion])
rotate(180/8)
PolyCyl(Insert[OD],Insert[LENGTH] + 1*Protrusion,8);
translate([0,0,(StrutOffset - Insert[LENGTH] - Kerf/2 + Protrusion)])
rotate(180/8)
PolyCyl(Insert[OD],Insert[LENGTH] + 1*Protrusion,8);
}
translate([-BlockSize.x,0,0])
rotate([0,90,0])
cylinder(d=LightOD,h=2*BlockSize.x,$fn=NumSides);
translate([0,BlockSize.y,StrutOffset])
rotate([90,0,0])
cylinder(d=StrutOD,h=2*BlockSize.y,$fn=NumSides);
translate([0,0,StrutOffset])
cube([2*BlockSize.x,2*BlockSize.y,Kerf],center=true);
cube([2*BlockSize.x,2*BlockSize.y,Kerf],center=true);
}
}
//- Build it
if (Layout == "Block")
if (Section)
difference() {
Block();
rotate(atan(ScrewOC.y/ScrewOC.x))
translate([0,BlockSize.y,0])
cube(2*BlockSize,center=true);
}
else
Block();
if (Layout == "Show") {
Block();
color("Green",0.25)
translate([-BlockSize.x,0,0])
rotate([0,90,0])
cylinder(d=LightOD,h=2*BlockSize.x,$fn=NumSides);
color("Green",0.25)
translate([0,BlockSize.y,StrutOffset])
rotate([90,0,0])
cylinder(d=StrutOD,h=2*BlockSize.y,$fn=NumSides);
}
if (Layout == "Build") {
translate([-1.2*BlockSize.x,0,-BaseOffset])
difference() {
Block();
translate([0,0,BlockSize.z])
cube(2*BlockSize,center=true);
}
translate([1.2*BlockSize.x,0,StrutOD/2 + WallThick])
difference() {
rotate([180,0,0])
translate([0,0,-StrutOffset])
Block();
translate([0,0,BlockSize.z])
cube(2*BlockSize,center=true);
}
translate([0,0,StrutOffset - Kerf/2])
rotate([180,0,0])
intersection() {
Block();
translate([0,0,StrutOffset/2])
cube([2*BlockSize.x,2*BlockSize.y,StrutOffset],center=true);
}
}

Tour Easy: Bafang BBS02 Lower Power

It turns out Mary rarely used assist level 6 and had no use for levels 7 and 8 of my derated BBS02 configuration:

LC=15
ALC0=0
ALC1=5
ALC2=7
ALC3=16
ALC4=25
ALC5=37
ALC6=51
ALC7=67
ALC8=85
ALC9=100

Level 9 must be 100% of the maximum motor current so the throttle can apply full power to get out of the way in a hurry.

The new and even more derated configuration allows small-step assist level selection for our usual riding, at the cost of an unused huge step to level 9 for the throttle:

[Basic]
LBP=42
LC=18
ALC0=0
ALC1=4
ALC2=6
ALC3=9
ALC4=15
ALC5=20
ALC6=25
ALC7=30
ALC8=40
ALC9=100
ALBP0=0
ALBP1=100
ALBP2=100
ALBP3=100
ALBP4=100
ALBP5=100
ALBP6=100
ALBP7=100
ALBP8=100
ALBP9=100
WD=12
SMM=0
SMS=1
[Pedal Assist]
PT=3
DA=0
SL=0
SSM=4
WM=0
SC=20
SDN=4
TS=15
CD=8
SD=5
KC=100
[Throttle Handle]
SV=11
EV=42
MODE=1
DA=10
SL=0
SC=5

The LC=18 line limits the maximum motor current to 18 A, rather than the rated 24 A, which may improve controller MOSFET longevity; reliable evidence is hard to come by. Controller failures seem to happen more often to riders who value jackrabbit acceleration on harsh terrain, so it may make little difference for road cyclists.

So level 5 now selects 75% × 20% = 15% of the motor’s nominal 750 W:

Tour Easy Bafang - display 26 mi
Tour Easy Bafang – display 26 mi

Call it 115 W: we’re both getting plenty of exercise!

Naming Is Hard

A recent update to the X Windowing System (or whatever it’s called) once again changed the names of its monitors / displays / output devices, so that my startup script no longer confined the tablet to the landscape display.

In mostly reverse chronological order, here are various commands I’ve puzzled out:

#xsetwacom --verbose set "HUION Huion Tablet stylus" MapToOutput "DP1-8"
xsetwacom --verbose set "HUION Huion Tablet stylus" MapToOutput "DP-1-8"
#xsetwacom --verbose set "HUION Huion Tablet Pen stylus" MapToOutput "DP-1"
#xsetwacom --verbose set "Wacom Graphire3 6x8 Pen stylus" MapToOutput "DP-1"
#xsetwacom --verbose set "Wacom Graphire3 6x8 Pen stylus" MapToOutput "HEAD-0"
#xsetwacom --verbose set "Wacom Graphire3 6x8 Pen eraser" MapToOutput "DP-1"
#xsetwacom --verbose set "Wacom Graphire3 6x8 Pen eraser" MapToOutput "HEAD-0"

Over the last two years, the display name changed from DP-1 to DP-1-8 to DP1-8, and back to DP-1-8. I grew accustomed to this with the Wacom tablet (HEAD-0‽)and now know where to look, but I still have no idea of the motivation.

Aaaand the tablet’s stylus name? The Wacom names were stable, but the Huion names apparently come from the Department of Redundancy Department.

Arduino MEGA Debugging LEDs

Kibitzing on a project involving an Arduino Mega (properly MEGA, but who cares?) with plenty of spare I/O pins led me to slap together a block of LEDs:

Arduino Mega Debugging LEDs
Arduino Mega Debugging LEDs

The excessive lead length on the 330 Ω resistors will eventually anchor scope probes syncing on / timing interesting program events.

Not that you have any, but they’re antique HP HDSP-4836 tuning indicators: RRYYGGYYRR. If you were being fussy, you might use 270 Ω resistors on the yellow LEDs to brighten them up.

A simple test program exercises the LEDs:

/*
  Debugging LED outputs for Mega board
  Ed Nisley - KE4ZNU
  Plug the board into the Digital Header pins 34-52 and GND 
*/

byte LowLED = 34;
byte HighLED = 52;
byte ThisLED = LowLED;

//-----
void setup() {
  pinMode(LED_BUILTIN,OUTPUT);
  
  for (byte p = LowLED; p <= HighLED; p+=2)
    pinMode(p, OUTPUT);

//  Serial.begin(9600);
}

// -----
void loop() {
  digitalWrite(LED_BUILTIN,HIGH);
  
  digitalWrite(ThisLED, HIGH);
  delay(100);
  digitalWrite(ThisLED, LOW);
 // delay(500);

  ThisLED = (ThisLED < HighLED) ? (ThisLED + 2) : LowLED;

//  Serial.println(ThisLED);

  digitalWrite(LED_BUILTIN,LOW);
}

Nothing fancy, but it ought to come in handy at some point.