Atreus Keyboard: LED Thoughts

Having helped grossly over-fund the Atreus Kickstarter earlier this year, a small box arrived pretty much on-time:

Atreus keyboard - overview
Atreus keyboard – overview

I did get the blank keycap set, but have yet to screw up sufficient courage to install them. The caps sit atop the stock Kailh (pronounced, I think, kale) BOX Brown soft tactile switches; they’re clicky, yet not offensively loud.

Removing a dozen screws lets you take it apart, revealing all the electronics on the underside of the PCB:

Atreus keyboard - PCB overview
Atreus keyboard – PCB overview

The central section holds most of the active ingredients:

Atreus keyboard - USB 32U4 Reset - detail
Atreus keyboard – USB 32U4 Reset – detail

The Atmel MEGA32U4 microcontroller runs a slightly customized version of QMK:

Atreus keyboard - 32U4 - detail
Atreus keyboard – 32U4 – detail

Of interest is the JTAG header at the front center of the PCB:

Atreus keyboard - JTAG header
Atreus keyboard – JTAG header

I have yet to delve into the code, but I think those signals aren’t involved with the key matrix and one might be available to drive an addressable RGB LED.

For future reference, they’re tucked into the lower left corner of the chip (the mauled format comes from the original PDF):

Atmel 32U4 - JTAG pins
Atmel 32U4 – JTAG pins

The alternate functions:

  • SCK = PB1
  • MOSI = PB2
  • MISO = PB3

I don’t need exotic lighting, but indicating which key layer is active would be helpful.

Love the key feel, even though I still haven’t hit the B key more than 25% of the time.

Raspberry Pi Interrupts vs. Rotary Encoder

Thinking about using a rotary encoder to focus a Raspberry Pi lens led to a testbed:

RPi knob encoder test setup
RPi knob encoder test setup

There’s not much to it, because the RPi can enable pullup resistors on its digital inputs, whereupon the encoder switches its code bits to common. The third oscilloscope probe to the rear syncs on a trigger output from my knob driver.

I started with the Encoder library from PyPi, but the setup code doesn’t enable the pullup resistors and the interrupt (well, it’s a callback) handler discards the previous encoder state before using it, so the thing can’t work. I kept the overall structure, gutted the code, and rebuilt it around a state table. The code appears at the bottom, but you won’t need it.

Here’s the problem, all in one image:

Knob Encoder - ABT - fast - overview
Knob Encoder – ABT – fast – overview

The top two traces are the A and B encoder bits. The bottom trace is the trigger output from the interrupt handler, which goes high at the start of the handler and low at the end, with a negative blip in the middle when it detects a “no motion” situation: the encoder output hasn’t changed from the last time it was invoked.

Over on the left, where the knob is turning relatively slowly, the first two edges have an interrupt apiece. A detailed view shows them in action (the bottom half enlarge the non-shaded part of the top half):

Knob Encoder - ABT - fast - first IRQs
Knob Encoder – ABT – fast – first IRQs

Notice that each interrupt occurs about 5 ms after the edge causing it!

When the edges occur less than 5 ms apart, the driver can’t keep up. The next four edges produce only three interrupts:

Knob Encoder - ABT - fast - 4 edges 3 IRQ
Knob Encoder – ABT – fast – 4 edges 3 IRQ

A closer look at the three interrupts shows all of them produced the “no motion” pulse, because they all sampled the same (incorrect) input bits:

Knob Encoder - ABT - fast - 4 edges 3 IRQ - detail
Knob Encoder – ABT – fast – 4 edges 3 IRQ – detail

In fact, no matter how many edges occur, you only get three interrupts:

Knob Encoder - ABT - fast - 9 edges 3 IRQ
Knob Encoder – ABT – fast – 9 edges 3 IRQ

The groups of interrupts never occur less than 5 ms apart, no matter how many edges they’ve missed. Casual searching suggests the Linux Completely Fair Scheduler has a minimum timeslice / thread runtime around 5 ms, so the encoder may be running at the fastest possible response for a non-real-time Raspberry Pi kernel, at least with a Python handler.

If. I. Turn. The. Knob. Slowly. Then. It. Works. Fine. But. That. Is. Not. Practical. For. My. Purposes.

Nor anybody else’s purposes, really, which leads me to think very few people have ever tried lashing a rotary encoder to a Raspberry Pi.

So, OK, I’ll go with Nearer and Farther focusing buttons.

The same casual searching suggested tweaking the Python thread’s priority / niceness could lock it to a different CPU core and, obviously, writing the knob handler in C / C++ / any other language would improve the situation, but IMO the result doesn’t justify the effort.

It’s worth noting that writing “portable code” involves more than just getting it to run on a different system with different hardware. Rotary encoder handlers are trivial on an Arduino or, as in this case, even an ARM-based Teensy, but “the same logic” doesn’t deliver the same results on an RPi.

My attempt at a Python encoder driver + simple test program as a GitHub Gist:

# Rotary encoder test driver
# Ed Nisley - KE4ZNU
# Adapted from https://github.com/mivallion/Encoder
# State table from https://github.com/PaulStoffregen/Encoder
import RPi.GPIO as GPIO
class Encoder(object):
def __init__(self, A, B, T=None, Delay=None):
GPIO.setmode(GPIO.BCM)
self.T = T
if T is not None:
GPIO.setup(T, GPIO.OUT)
GPIO.output(T,0)
GPIO.setup(A, GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(B, GPIO.IN, pull_up_down=GPIO.PUD_UP)
self.delay = Delay
self.A = A
self.B = B
self.pos = 0
self.state = (GPIO.input(B) << 1) | GPIO.input(A)
self.edges = (0,1,-1,2,-1,0,-2,1,1,-2,0,-1,2,-1,1,0)
if self.delay is not None:
GPIO.add_event_detect(A, GPIO.BOTH, callback=self.__update,
bouncetime=self.delay)
GPIO.add_event_detect(B, GPIO.BOTH, callback=self.__update,
bouncetime=self.delay)
else:
GPIO.add_event_detect(A, GPIO.BOTH, callback=self.__update)
GPIO.add_event_detect(B, GPIO.BOTH, callback=self.__update)
def __update(self, channel):
if self.T is not None:
GPIO.output(self.T,1) # flag entry
state = (self.state & 0b0011) \
| (GPIO.input(self.B) << 3) \
| (GPIO.input(self.A) << 2)
gflag = '' if self.edges[state] else ' - glitch'
if (self.T is not None) and not self.edges[state]: # flag no-motion glitch
GPIO.output(self.T,0)
GPIO.output(self.T,1)
self.pos += self.edges[state]
self.state = state >> 2
# print(' {} - state: {:04b} pos: {}{}'.format(channel,state,self.pos,gflag))
if self.T is not None:
GPIO.output(self.T,0) # flag exit
def read(self):
return self.pos
def read_reset(self):
rv = self.pos
self.pos = 0
return rv
def write(self,pos):
self.pos = pos
if __name__ == "__main__":
import encoder
import time
from gpiozero import Button
btn = Button(26)
enc = encoder.Encoder(20, 21,T=16)
prev = enc.read()
while not btn.is_held :
now = enc.read()
if now != prev:
print('{:+4d}'.format(now))
prev = now
view raw encoder.py hosted with ❤ by GitHub

USB Charger: Abosi Waveforms

For comparison with the Anonymous White Charger of Doom, I bought a trio of Abosi USB chargers:

Abosi charger - dataplate
Abosi charger – dataplate

The symbology indicates it’s UL, but not CE, listed. Consumer Reports has a guide to some of the symbols; I can’t find anything more comprehensive.

Applying the same 8 Ω + 100 µF load as before:

Abosi charger - 8 ohm 100 uF detail - 100 ma-div
Abosi charger – 8 ohm 100 uF detail – 100 ma-div

The voltage (yellow) and current (green, 100 mA/div) waveforms look downright tame compared to some of the other chargers!

I made a cursory attempt to crack the case open, but gave up before doing any permanent damage. Hey, that UL listing (and, presumably, the interior details) means they’re three times the price of those Anonymous chargers!

Anonymous White USB Charger: Teardown

Prompted by ericscott’s comment, I had to tear down the Anonymous White USB Charger to see what caused the bizarre current waveform when connected to the Arduino in a Glass Tile:

Tiles 2x2 - anon white charger - pulse detail - 50 mA-div
Tiles 2×2 – anon white charger – pulse detail – 50 mA-div

Start by grabbing opposite corners in a small vise and gently cracking the solvent-bonded joint between the sections:

Anon white charger - case cracking
Anon white charger – case cracking

Pull the base past the molded latches:

Anon white charger - case opened
Anon white charger – case opened

Behold: components!

Anon white charger - PCB top
Anon white charger – PCB top

On both sides of both PCBs!

Anon white charger - PCB bottom
Anon white charger – PCB bottom

The top half of both boards, above the isolation cut, handles the line voltage and the lower half handles the 5 V USB output. You’ll note the absence of extra-cost parts like voltage feedback or ahem safety fuses.

The IC on the right half is labeled DP3773, which doesn’t seem to exist, but is surely similar to the LP3773 Low-Power Off-Line / PSR Controller.

Treating the whole regulator as a black box simplifies the schematic:

Anonymous white charger - schematic
Anonymous white charger – schematic

The cap bridging the two sides should be a Y capacitor, but it’s an ordinary 1 nF ceramic cap with a generous 1 kV rating. As far as I can tell, having it inject AC line noise directly into the +5 V side of the USB supply is just a bonus.

The base markings again:

Anonymous white charger - dataplate
Anonymous white charger – dataplate

Whaddaya want for a buck, right?

Other folks give better teardown pr0n

Glass Tiles: USB Charger Current Waveforms

Looking at what comes out of various USB chargers, with the Tek current probe monitoring the juice:

USB Current-Probe Extender - in action
USB Current-Probe Extender – in action

First, a known-good bench supply set to 5.0 V:

Tiles 2x2 - bench supply - 50 mA-div
Tiles 2×2 – bench supply – 50 mA-div

The yellow trace is the Glass Tile Heartbeat output, which goes high during the active part of the loop. The purple trace shows the serial data going to the SK6812 RGBW LEDs. The green trace is the USB current at 50 mA/div, with the Glass Tile LED array + Arduino drawing somewhere between 50 and 100 mA; most of that goes to the LEDs.

The current steps downward by about 10 mA just after the data stream ends, because that’s where the LEDs latch their new PWM values. The code is changing a single LED from one color to another, so the current will increase or decrease by the difference of the two currents.

A charger from my Google Pixel 3a phone (actually made by Flextronics and, uniquely, UL listed), with Google’s ever-so-trendy and completely unreadable medium gray lettering on a light gray plastic body:

Google Pixel charger - dataplate
Google Pixel charger – dataplate

The current waveform looks only slightly choppy:

Tiles 2x2 - Google Flextronics charger - 50 mA-div
Tiles 2×2 – Google Flextronics charger – 50 mA-div

An AmazonBasics six-port USB charger from tested by Intertek:

AmazonBasics charger - dataplate
AmazonBasics charger – dataplate

The waveform:

Tiles 2x2 - Amazon Basics Intertek Basics charger - 50 mA-div
Tiles 2×2 – Amazon Basics Intertek Basics charger – 50 mA-div

A blackweb (their lack of capitalization) charger, also made tested by Intertek:

blackweb charger - dataplate
blackweb charger – dataplate

The current:

Tiles 2x2 - blackweb charger - 50 mA-div
Tiles 2×2 – blackweb charger – 50 mA-div

Finally, one from a lot of dirt-cheap chargers from eBay:

Anonymous white charger - dataplate
Anonymous white charger – dataplate

Which has the most interesting current waveform of all:

Tiles 2x2 - anon white charger - 50 mA-div
Tiles 2×2 – anon white charger – 50 mA-div

A closer look:

Tiles 2x2 - anon white charger - pulse detail - 50 mA-div
Tiles 2×2 – anon white charger – pulse detail – 50 mA-div

From the 75 mA baseline, the charger is ramming 175 mA pulses at 24 kHz into the filter cap on the Arduino Nano PCB! The green trace has a few seconds of (digital) persistence, so you’re seeing a lot of frequency jitter; the pulses most likely come from a voltage comparator controlling the charger’s PWM cycle.

It’s about what one should expect for $1.28 apiece, right?

They’re down to $1.19 today: who knows what the waveform might be?

Update: Having gotten a clue from a comment posted instantly after I fat-fingered the schedule for this post, I now know Intertek is a testing agency, not a manufacturer.

USB Current Probe Extender

Having gotten two answers from two USB meters, I figured it was time to get primal:

USB Current-Probe Extender - wiring
USB Current-Probe Extender – wiring

That’s a pair of USB breakout connectors and lengths of nice silicone wire (24 AWG power & 28 AWG data), with just enough slack for a Tek A6302 current probe:

USB Current-Probe Extender - in action
USB Current-Probe Extender – in action

So I can see the actual current waveform of a Glass Tile box running from a bench power supply:

Tiles 2x2 - bench supply - 50 mA-div
Tiles 2×2 – bench supply – 50 mA-div

The top trace is the firmware heartbeat from the Arduino Nano, the middle trace is the SK6812 LED data stream, and the bottom trace is the USB current at 50 mA/div. The current steps downward by about 10 mA (just after the data burst) when one of the tiles changes color and and LED shuts off.

The current probe reveals some mysteries, such as this waveform from a dirt-cheap USB charger:

Tiles 2x2 - anon white charger - 50 mA-div
Tiles 2×2 – anon white charger – 50 mA-div

I wonder why it’s ramming 100 mA current spikes into the circuit, too. At least now I can see what’s going on.

Glass Tiles: Matrix for SK6812 PCBs

Tweaking the glass tile frame for press-fit SK6812 PCBs in the bottom of the array cells:

Glass Tile Frame - cell array - openscad
Glass Tile Frame – cell array – openscad

Which looks like this with the LEDs and brass inserts installed:

Glass Tile - 2x2 array - interior
Glass Tile – 2×2 array – interior

The base holds an Arduino Nano with room for wiring under the cell array:

Glass Tile Frame - base - openscad
Glass Tile Frame – base – openscad

Which looks like this after it’s all wired up:

Glass Tile - 2x2 array - wiring
Glass Tile – 2×2 array – wiring

The weird colors showing through the inserts are from the LEDs. The red thing in the upper left is a silicone insulation snippet. Yes, that’s hot-melt glue holding the Arduino Nano in place and preventing the PCBs from getting frisky.

Soak a handful of glass tiles overnight in paint stripper:

Glass Tiles - paint stripper soak
Glass Tiles – paint stripper soak

Whereupon the adhesive slides right off with the gentle application of a razor scraper. Rinse carefully, dry thoroughly, and snap into place.

Tighten the four M3 SHCS and it’s all good:

Glass Tile - 2x2 array - operating
Glass Tile – 2×2 array – operating

So far, I’ve had two people tell me they don’t know what it is, but they want one:

Glass Tile - various versions
Glass Tile – various versions

The OpenSCAD Customizer lets you set the array size:

Glass Tile Frame - 3x3 - press-fit SK6812 LEDs
Glass Tile Frame – 3×3 – press-fit SK6812 LEDs

However, just because you can do something doesn’t mean you should:

Glass Tile Frame - 6x6 cell array - openscad
Glass Tile Frame – 6×6 cell array – openscad

Something like this might be interesting:

Glass Tile Frame - 2x6 cell array - openscad
Glass Tile Frame – 2×6 cell array – openscad

In round numbers, printing the frame takes about an hour per cell, so a 2×2 array takes three hours and 3×3 array runs around seven hours. A 6×6 frame is just not happening.

The OpenSCAD source code as a GitHub Gist:

// Illuminated Tile Grid
// Ed Nisley - KE4ZNU
// 2020-05
/* [Configuration] */
Layout = "Build"; // [Cell,CellArray,MCU,Base,Show,Build]
Shape = "Square"; // [Square, Pyramid, Cone]
Cells = [2,2];
CellDepth = 15.0;
Inserts = true;
SupportInserts = true;
/* [Hidden] */
ThreadThick = 0.25;
ThreadWidth = 0.40;
HoleWindage = 0.2;
function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
Protrusion = 0.1; // make holes end cleanly
ID = 0;
OD = 1;
LENGTH = 2;
Tile = [25.0 + 0.1,25.0 + 0.1,4.0];
WallThick = 4*ThreadWidth;
FloorThick = 3.0;
Flange = [2*ThreadWidth,2*ThreadWidth,0]; // ridge supporting tile
Separator = [3*ThreadWidth,3*ThreadWidth,Tile.z - 1]; // between tiles
Screw = [3.0,6.0,3.5]; // M3 SHCS, OD=head, LENGTH=head
Insert = [3.0,4.2,8.0]; // threaded brass insert
ScrewRecess = Screw[LENGTH] + 4*ThreadThick;
LEDPCB = [9.6,9.6,2.9]; // round SK6812, squared-off sides
LED = [5.0 + 2*HoleWindage,5.0 + 2*HoleWindage,1.3];
LEDOffset = [0.0,0.0,0.0]; // if offset from PCB center
CellOAL = [Tile.x,Tile.y,0] + Separator + [0,0,CellDepth] + [0,0,FloorThick];
ArrayOAL = [Cells.x*CellOAL.x,Cells.y*CellOAL.y,CellOAL.z]; // just the LED cells
BlockOAL = ArrayOAL + [2*WallThick,2*WallThick,0]; // LED cells + exterior wall
echo(str("Block OAL: ",BlockOAL));
InsertOC = ArrayOAL - [Insert[OD],Insert[OD],0] - [WallThick,WallThick,0];
echo(str("Insert OC: ",InsertOC));
TapeThick = 1.0;
Arduino = [44.0,18.0,8.0 + TapeThick]; // Arduino Nano to top of USB Mini-B plug
USBPlug = [15.0,11.0,9.0]; // USB Mini-B plug insulator
USBOffset = [0,0,5.0]; // offset from PCB base
WiringSpace = 3.5;
WiringBay = [(Cells.x - 1)*CellOAL.x + LEDPCB.x,(Cells.y - 1)*CellOAL.y + LEDPCB.x,WiringSpace];
PlateOAL = [BlockOAL.x,BlockOAL.y,FloorThick + Arduino.z + WiringSpace]; // allow wiring above Arduino
echo(str("Base Plate: ",PlateOAL));
echo(str("Screw length: ",(PlateOAL.z - ScrewRecess) + Insert.z/2," to ",(PlateOAL.z - ScrewRecess) + Insert.z));
LegendRecess = 1*ThreadThick;
//------------------------
module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
FixDia = Dia / cos(180/Sides);
cylinder(d=(FixDia + HoleWindage),h=Height,$fn=Sides);
}
//-----------------------
// Base and optics in single tile
module LEDCone() {
hull() {
translate([0,0,CellDepth + Tile.z/2])
cube(Tile - 2*[Flange.x,Flange.y,0],center=true);
if (Shape == "Square") {
translate([0,0,LEDPCB.z/2])
cube([Tile.x,Tile.y,LEDPCB.z] - 2*[Flange.x,Flange.y,0],center=true);
}
else if (Shape == "Pyramid") {
translate([0,0,LEDPCB.z/2])
cube(LEDPCB,center=true);
}
else if (Shape == "Cone") {
translate([0,0,LEDPCB.z/2])
cylinder(d=1.0*LEDPCB.x,h=LED.z,center=true);
}
else {
echo(str("Whoopsie! Invalid Shape: ",Shape));
cube(5);
}
}
}
// One complete LED cell
module LEDCell() {
difference() {
translate([0,0,CellOAL.z/2])
cube(CellOAL + [Protrusion,Protrusion,0],center=true); // force overlapping adjacent sides!
translate([0,0,CellOAL.z - Separator.z + Tile.z/2])
cube(Tile,center=true);
translate([0,0,LEDPCB.z])
LEDCone();
// cube([LED.x,LED.y,CellOAL.z],center=true);
translate(-LEDOffset + [0,0,-CellOAL.z/2])
rotate(180/8)
PolyCyl(LEDPCB.x,CellOAL.z,8);
}
}
// The whole array of cells
module CellArray() {
difference() {
union() {
translate([CellOAL.x/2 - Cells.x*CellOAL.x/2,CellOAL.y/2 - Cells.y*CellOAL.y/2,0])
for (i=[0:Cells.x - 1], j=[0:Cells.y - 1])
translate([i*CellOAL.x,j*CellOAL.y,0])
LEDCell();
if (Inserts) // bosses
for (i=[-1,1], j=[-1,1])
translate([i*InsertOC.x/2,j*InsertOC.y/2,0])
rotate(180/8)
cylinder(d=Insert[OD] + 2*WallThick,h=Insert[LENGTH],$fn=8);
}
if (Inserts) // holes
for (i=[-1,1], j=[-1,1])
translate([i*InsertOC.x/2,j*InsertOC.y/2,-Protrusion])
rotate(180/8)
PolyCyl(Insert[OD],Insert[LENGTH] + FloorThick + Protrusion,8);
}
difference() {
translate([0,0,CellOAL.z/2])
cube(BlockOAL,center=true);
translate([0,0,CellOAL.z])
cube(ArrayOAL + [0,0,2*CellOAL.z],center=true);
}
}
// Arduino bounding box
// Origin at center bottom of PCB
module Controller() {
union() {
translate([0,0,Arduino.z/2])
cube(Arduino,center=true);
translate([Arduino.x/2 - Protrusion,-USBPlug.y/2,USBOffset.z + TapeThick - USBPlug.z/2])
cube(USBPlug + [Protrusion,0,0],center=false);
}
}
// Baseplate
module BasePlate() {
difference() {
translate([0,0,PlateOAL.z/2])
cube(PlateOAL,center=true);
translate([PlateOAL.x/2 - Arduino.x/2 - 2*WallThick,0,FloorThick])
Controller();
translate([PlateOAL.x/2 - Arduino.x/2 - 2*WallThick,0,FloorThick + PlateOAL.z/2])
cube([Arduino.x - 2*2.0,WiringBay.y,PlateOAL.z],center=true); // cutouts beside MCU
translate([0,0,PlateOAL.z - WiringBay.z + PlateOAL.z/2 - Protrusion])
cube([PlateOAL.x - 2*WallThick,WiringBay.y,PlateOAL.z],center=true); // cutout above MCU
translate([0,0,PlateOAL.z - WiringBay.z + PlateOAL.z/2 - Protrusion])
cube([WiringBay.x,PlateOAL.y - 2*WallThick,PlateOAL.z],center=true); // cutout above MCU
if (Inserts)
for (i=[-1,1], j=[-1,1])
translate([i*InsertOC.x/2,j*InsertOC.y/2,-Protrusion])
rotate(180/8) {
PolyCyl(Screw[ID],2*PlateOAL.z,8);
PolyCyl(Screw[OD],ScrewRecess + Protrusion,8);
}
cube([45,17.0,2*LegendRecess],center=true);
}
linear_extrude(height=2*LegendRecess) {
translate([0,1])
rotate(-0*90) mirror([1,0,0])
text(text="Ed Nisley",size=6,font="Arial:style:Bold",halign="center");
translate([0,-6.5])
rotate(-0*90) mirror([1,0,0])
text(text="softsolder.com",size=4.5,font="Arial:style:Bold",halign="center");
}
Fin = [Screw[OD]/2 - 1.5*ThreadWidth,2*ThreadWidth,ScrewRecess - ThreadThick];
if (Inserts && SupportInserts)
color("Yellow")
for (i=[-1,1], j=[-1,1])
translate([i*InsertOC.x/2,j*InsertOC.y/2,0]) {
rotate(180/8)
cylinder(d=6*ThreadWidth,h=ThreadThick,$fn=8);
for (a=[0:90:360])
rotate(a)
translate([Fin.x/2 + ThreadWidth/2,0,(ScrewRecess - ThreadThick)/2])
cube(Fin,center=true);
}
}
//-----------------------
// Build things
if (Layout == "Cell")
LEDCell();
else if (Layout == "CellArray")
CellArray();
else if (Layout == "MCU")
Controller();
else if (Layout == "Base")
BasePlate();
else if (Layout == "Show") {
translate([0,0,3*PlateOAL.z])
CellArray();
BasePlate();
translate([PlateOAL.x/2 - Arduino.x/2 - 2*WallThick,0,FloorThick])
color("Orange",0.3)
Controller();
}
else if (Layout == "Build") union() {
translate([0,0.6*BlockOAL.y,0])
CellArray();
translate([0,-0.6*BlockOAL.x,0])
rotate(90)
BasePlate();
}