Besting Ben Heck (use a 33 cent microcontroller to drive NeoPixels instead of multivibrators)

Recently Ben "Heck" posted a video that shows his approach to controlling "NeoPixels" (also known as WS2812 LEDs) via the SPI bus.  Standard NeoPixels are daisy-changed devices that are controlled by sending a series of "long" or "short" pulses (typically 24) to each pixel.  The first pixel in the chain reads the first 24 pulses, interprets each as either a 0 bit (short pulse), or 1 bit (long pulse) and then converts these 24 bits into an RBG color value.  After receiving its 24 bits value, the first device then passes thru all further pulses to the pixels further down the chain.  So, for example, sending 24 * n pulses will cause the first n pixels in the chains to each light up with a color value defined by the unique 24 bit value each received.  These pulses can be sent at up to 800,000 pulses/second, but each pulse require require precise timing to be read correctly.  In particular:

In his video, Mr Heckendorn explains how he wanted to use an ESP32 module to control some NeoPixels using the SPI bus to output the pixel data.  Therefore, he needed a way to convert the stream of bits from the SPI bus into a series of these precisely-timed pulses.  His solution, was to use a pair of monostable, or "one shot", multivibrators to generate the two different pulse lengths.  Frankly, while they have their uses, I'm not a big fan of using one-shot multivibrators as timing elements as they nearly always require using variable resistors to "tweak" the timing due to the imprecision of the kinds of capacitors you can buy at reasonable prices. In addition, they are subject to drift due to temperature change and voltage fluctuations, as well as long term drift due to component aging.

So, I found myself wondering how I might both improve and simplify Mr Heckendorn's scheme using an all digital approach.  My solution was to use my favorite tiny microcontroller  the ATTiny10, as the master timing element.  The ATTiny10 costs 33 cents (quantity 1) and comes from the factory with an internal, calibrated, 8 MHz oscillator.  Using a microprocessor to generate timing sequences is an "old school" technique that was the basis of how video games on the Atari 2600 were coded.  The uniquely-creative Don Lancaster also used a variation of this technique to create advanced TV Typewriters in his books "The Cheap Video Cookbook" and "Son of Cheap Video". While not practiced much any more, it's still a useful technique to consider, especially as the cost of 8 bit microcontrollers has dropped significantly over time.  

The basic idea is to use the time it takes to execute instructions in the microcontroller's CPU as the basis used to generate the pulses needed to control the NeoPixels.  Unlike the modern, superscalar CPU's used in desktop computers, lowly microcontrollers are designed to process instructions at simple, regular rates.  For example, running at 8 MHz, the ATTiny10's CPU has a cycle time of 150 ns and most instructions execute in one or two cycles.  This makes it fairly easy to calculate how long a particular sequence of instructions will take to execute.  In addition, the AVR s series of microcontrollers, on which the ATTiny10 is based, has two very useful instructions, cbi and sbi, that can perform a write operation to an I/O pin in a single cycle.  This means we can use a chain of these instructions to, in theory, generate pulses on an I/O pin to multiples of 150 ns.  

There are other factors to consider, such that there are other internal timing delays that dictate how quickly the I/O pin responds to the instruction.  But, it's quite easy to hook up an oscilloscope to a pin to verify and/or tweak the timing by adding, removing or reordering instructions.  After some experimentation, the following scope capture shows the result I came up with:

Click image for larger view

The bottom two traces how the the two SPI signals and the top trace shows the pulses generated by the code.  While it's hard to see in this trace, when zoomed in, the top trace showed the short pulse was roughly 400 nanoseconds iength and the long pulse was 900 nanoseconds.  Note: this timing could vary from ATTiny10 to ATTiny10, as the as the factory calibration  of ATTiny10's "Internal Calibrated Oscillator" is only guaranteed to +/- 10%, but that should be sufficient for this application.  For other applications that might need more precise timing, the ATTiny10 provides a method of user calibration (use clock calibration is supported by my ATTiny10IDE project, which you can read more about by following the link.)  And, here's the complete source code (consisting of just 18 instructions) used to program the ATTiny10 to function as an SPI to NeoPixel timing adapter:

;     SPI (Mode 0) to NeoPixel Adapter using ATtiny10

;

;                +====+

;     SCK -> PB0 |*   | PB3 (RESET)

;            GND |    | Vcc

;    MOSI -> PB1 |    | PB2 -> Output to NeoPixels

;                +====+

CLK = 0        ; PB0

DIN = 1        ; PB1

OUT = 2        ; PB2

PINB = 0x00

DDRB = 0x01

PORTB = 0x02

CCP  = 0x3C

CLKPSR = 0x36

.section .text

  

eor   r17,  r17      ; Set r17 = 0

ldi   r20, 0xD8      ; Unprotect CLKPSR reg (CCP = 0xD8)

out   CCP, r20  

out   CLKPSR, r17    ; Set Clock Prescaler to 1:1 (CLKPSR = 0)

sbi   DDRB, OUT      ; Set PB2 as Output

loop1:

sbis  PINB, CLK      ; Wait for CLK to go HIGH (rising edge)

rjmp  loop1

sbi   PORTB, OUT     ; Set Output HIGH to begin pulse to Neopixel

nop                  ; Delay for "0" bit

sbis  PINB, DIN      ; Skip if DIN "1" bit

cbi   PORTB, OUT     ; Set Output LOW to end "0" bit pulse to Neopixel

nop                  ; Delay for "1" bit

nop                  ; Delay for "1" bit

nop                  ; Delay for "1" bit

cbi   PORTB, OUT     ; Set Output LOW to end "1" bit pulse to Neopixel

loop2:

sbic  PINB, CLK      ; Wait for CLK to go LOW (falling edge)

rjmp  loop2

rjmp  loop1 

If you're not familiar with AVR assembly language, I'll explain a bit about how the code works.  The first 5 instructions, as indicated in the line comments, are used simply to set the ATTIny10's clock to 8 MHz and define Pin 4 (OUT) as an output (by default after a power on reset, the other pins remain configured as inputs.)  Then, the combination of the sbis instruction followed by the rjmp (relative jump) instruction form a loop that waits for Pin 1 (the CLK input) to go HIGH.  When it does, the sbis instruction "skips" over the rjmp instruction to break the loop.  The sbi instruction which follows then sets the pulse output on Pin 4 to HIGH, thus starting either a long, or a short output pulse and a nop (no operation) instruction adds another 150 ns.  The sbis instruction that follows the nop then tests the state of the data input on Pin 3 (DIN) and, if the pin is LOW, it executes the next instruction, cbi, which sets Pin 4 (OUT) back to the LOW state (ending the "short" output pulse.)  However, if Pin 3 is HIGH, it skips over the sbi instruction and Pin 4 stays HIGH.  The nop instructions that follow simply allow additional time to pass before the cbi instruction that follows sets Pin 3 back to LOW (ending the "long" output pulse.)  Finally, the sbic and rjmp instructions loop until Pin 1 (CLK) to return to LOW before the final rjmp jumps back to the start of the program.

IMPORTANT: as noted in the code, SPI Mode 0 is assumed for the SPI transfer rather than the Mode 1 transfer Mr Heckendorn used.  The SPI clock signal (SCK) is fed into pin 1 and Pin 3 takes the data input from the SPI Master's MOSI signal.  Pin 4 of the ATTiny10 then outputs the NeoPixel pulses.  Ideally, Vcc to the ATTiny10 should be 5 volts, but the data sheet indicates it can operate at 3.3 volts when running at 8 MHz.  However, since NeoPixels are designed to run at 5 volts, you may need to add a level converter if you want to run the ATTiny10 at a lower voltage than 5 volts, as NeoPixels are said not to respond well to input pulses with an amplitude of less than .7 times Vcc.

Testing the Adapter

I didn't have an ESP32 module available to test my code in the same way as was shown in Mr. Heckendorn's video, so I coded up a simple Arduino sketch, SpiToNeopixelTest.ino,  that writes a test sequence (a pixel "chase" display") to the Arduino's SPI output.  You can download this sketch below and import it into the Arduino IDE.  Details on wiring the output of the Arduino to the ATTiny10-based timing adapter are included in the code.  Note: Mr Heckendorn used SPI Mode 1 for his circuit whereas my test sketch and my ATiny10-based adapter use SPI Mode 0, as that the most common usage on an Arduino.  Note: you can make the adapter compatible with SPI Mode 1 by swapping the first "sbis" instruction and the "sbic" instruction never the end (shown in bold in the code above.)  That will make the code sample the data line on the falling edge of the clock instead of on the positive edge.

How I Coded and Developed this Project

I used my own ATTiny10IDE development environment to code and program the ATTiny10 used in this project, as it supports working in both C and assembly.  In you're interested, you can read more about ATTiny10IDE at its GitHub page where you can get both the complete, Java source code, or download just an executable version in the form of a .jar file that runs on 64 bit macOs, Window and Linux.

Alternately, if you just want to program an ATTiny10 with the code shown above so you can try it out for yourself, you can download the SpiToNeopixel-prog.ino Arduino sketch linked below.  When loaded to a 328P-based Arduino, such as the UNO, this sketch will act as a programmer to program the code into an ATTiny10.  When you open the Arduino's "Monitor" window (set to 115200 baud), the code will print instructions on how to connect the ATTiny10 to the Arduino and then program it.