Introduction: Respberry Pi Pico W NeoPixels Experiments With Programmable I/O (PIO) Using MicroPython

As a followup of my previous post showing Raspberry Pi Pico W Programmable I/O (PIO) experiments -- Respberry Pi Pico W Generating Tones With Programmable I/O (PIO) Using MicroPython -- in this post, I will switch gears to NeoPixels -- driving of NeoPixels with PIO in MicroPython.

Yes, I am aware that there are already standard MicroPython APIs for driving NeoPixels. Why then implement my own in PIO? The answer is simply: for the fun of it -- make mistakes and learn.

To demonstrate the PIO program that I use for driving NeoPixels here, two MicroPython programs will be presented. The first one simply blinks the NeoPixels in red, green, and blue. The second one will again make use of DumbDisplay for the UI -- a remote UI realized on your Android Phone -- for some basic fun effects.

Step 1: NeoPixels Basics

There are many NeoPixels resources you can find on the web, like the web site I "borrowed" the above GIF from. Here is my understanding on how to control NeoPixels. Basically

  • Each pixel (NeoPixel) is composed of 3 tiny LEDs for the three primary colors -- Red, Green, and Blue
  • Each primary color is controlled with 8 bits. Therefore, to control the [composite] color of each pixel, a total of 24 bits is needed -- and hence the 24 bit RGB888.
  • Nevertheless, the layout of the 3 primary colors of a pixel is GRB (Greed-Red-Blue) instead of RGB (Red-Green-Blue)
  • As a result, to control the color of a pixel, the 24 bits are laid out as
g7-g6-g5-g4-g3-g2-g1-g0-r7-r6-r5-r4-r3-r2-r1-r0-b7-b6-b5-b4-b3-b2-b1-b0

Hence, if we have 4 a chain pixels, a total of 96 bits are needed

pixel0: g7-g6-g5-g4-g3-g2-g1-g0-r7-r6-r5-r4-r3-r2-r1-r0-b7-b6-b5-b4-b3-b2-b1-b0
pixel1: g7-g6-g5-g4-g3-g2-g1-g0-r7-r6-r5-r4-r3-r2-r1-r0-b7-b6-b5-b4-b3-b2-b1-b0
pixel2: g7-g6-g5-g4-g3-g2-g1-g0-r7-r6-r5-r4-r3-r2-r1-r0-b7-b6-b5-b4-b3-b2-b1-b0
pixel3: g7-g6-g5-g4-g3-g2-g1-g0-r7-r6-r5-r4-r3-r2-r1-r0-b7-b6-b5-b4-b3-b2-b1-b0

To control (set) the colors of the chain of pixels, just send out pulses representing the bits to the IN-pin of the 1st pixel with the expected timing, starting from the left-most bit (i.e. the most significant bit of G of the 1st pixel). If the timing is as expected, the chain of NeoPixels is smart enough to know which input bit is for what color of which pixel. Just follow the bit-order and the pulse-timing

  • To represent 1 bit, IN-pin must be 0.4us HIGH followed by 0.85us LOW
  • To represent 0 bit, IN-pin must be 0.8us HIGH followed by 0.45us LOW
  • To reset (i.e. start all over again), IN-pin should be LOW for more than 50us
  • Allowance for the timing: +/- 0.15us

Therefore, if the PIO state machine is set to use a frequency of 20MHz (i.e. each cycle will take 0.05us)

  • To represent 1 bit, IN-pin must be 8 cycles HIGH followed by 17 cycles LOW
  • To represent 0 bit, IN-pin must be 16 cycles HIGH followed by 9 cycles LOW

Hence, the Python-syntax-pseudo-PIO program to set the colors of the pixels can be like

def set_pixels_colors():
osr = pull_blocked() # pull from FIFO number of pixels minus 1
y = osr # y <= number of pixels minus 1
while y != 0:
isr = y # isr (pixel counter) <= y
osr = pull_blicked() # pull from FIFO 24 bis GRB
x = 23 # x (bit counter) <= 23
while x != 0:
y = shift_osr() # y <= left-most bit of osr
if y != 0:
set(IN-pin, 1)
delay_cycles(15)
set(IN-pin, 0)
delay_cycles(8)
else:
set(IN-pin, 1)
delay_cycles(7)
set(IN-pin, 0)
delay_cycles(16)
x = x - 1
y = isr # y <= isr (pixel counter)
y = y - 1

It is assumed that when the PIO program is "called", FIFO will be supplying the following data

  • 1 word (32 bits) number of pixels - 1
sm.put(pixel_count - 1)
  • for each pixel, 1 word (24 bits) G-R-B value; note that since the PIO program is only expecting 24 bits from each word, the word is pre-shifted out 8 bits (i.e. discarded 8 left-most bits)
sm.put(grb, 8)

As a matter of fact, the PIO program flow is actually triggered by putting of the 1st word to the FIFO, which is the number of pixels - 1. The other words (color values) are put to the FIFO concurrently as the PIO program flow is in progress.

Step 2: NeoPixels Blink Test

Accordingly, here is an implementation of the above-described PIO program, as well as the Python code to blink 4 NeoPixels

import time
import rp2
from machine import Pin

# bits shifting
# =============
# - for the pixels: 1st pixel then 2nd pixel ...
# - for a pixel (24 bits): G then R then B
# - for R/G/B (8 bits): most significant bit first
# timing for a bit:
# - 0: .4us high + .85us low
# - 1: .8us high + .45us low
# if frequency is 20MHz ... i.e. each cycle takes 0.05us
# - 0: 8 cycles high + 17 cycles low
# - 1: 16 cycles high + 9 cycles low
# afterward, delay for 300us

NUM_PIXELS = 4
NEO_PIXELS_IN_PIN = 22

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT)  # SHIFT_LEFT: i.e. most significant bit first
def neo_prog():
    pull()                       # osr <= number of pixels - 1
    mov(y, osr)                  # y <= number of pixels - 1
    label("loop_pixel")
    mov(isr, y)                  # isr (pixel counter) <= y
    pull()                       # osr <= 24 bits GRB
    set(x, 23)                   # x (bit counter) <= 23
    label("loop_pixel_bit")
    out(y, 1)                    # y <= left-most 1 bit of osr
    jmp(not_y, "bit_0")
    set(pins, 1).delay(15)       # 1: high (16 cycles)
    set(pins, 0).delay(8)        # 1: low (9 cycles)
    jmp("bit_end")
    label("bit_0")
    set(pins, 1).delay(7)        # 0: high (8 cycles)
    set(pins, 0).delay(16)       # 0: low (17 cycles)
    label("bit_end")
    jmp(x_dec, "loop_pixel_bit") # x is bit counter
    mov(y, isr)                  # y <= isr (pixel counter)
    jmp(y_dec, "loop_pixel")     # y is pixel counter

sm = rp2.StateMachine(0, neo_prog, freq=20_000_000, set_base=Pin(NEO_PIXELS_IN_PIN))
sm.active(1)

def ShowNeoPixels(*pixels):
    '''
    each pixel is the tuple (r, g, b)
    '''
    pixel_count = len(pixels)
    sm.put(pixel_count - 1)
    for i in range(pixel_count):
        pixel = pixels[i]
        if pixel:
            (r, g, b) = pixel
        else:
            (r, g, b) = (0, 0, 0)
        grb = (g << 16) + (r << 8) + b    # the order is G R B
        sm.put(grb, 8)                    # a word is 32 bits, so, pre-shift out (discard) 8 bits, leaving 24 bits of the GRB
    time.sleep_us(300)                    # make sure the NeoPixels is reset for the next round

Pixels = []
for i in range(NUM_PIXELS):
    Pixels.append(None)

rgb = 0
i = 0
while True:
    if rgb == 0:
        c = (255, 0, 0)
    elif rgb == 1:
        c = (0, 255, 0)
    else:
        c = (0, 0, 255)
    Pixels[i] = c
    ShowNeoPixels(*Pixels)
    time.sleep(0.1)
    Pixels[i] = None
    ShowNeoPixels(*Pixels)
    rgb = (rgb + 1) % 3
    i = (i + 1) % NUM_PIXELS   

Notice the "annotation" of the PIO program neo_prog

@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT) # SHIFT_LEFT: i.e. most significant bit first

It states that

  • The PIO's pins -- will be set to the IN-pin of the chain of NeoPixels -- will initially be low
  • FIFO shift register will shift left (i.e. most significant bit / left most bit first)

And the construction of the PIO state machine

sm = rp2.StateMachine(0, neo_prog, freq=20_000_000, set_base=Pin(NEO_PIXELS_IN_PIN))

It states that

  • The PIO program for the PIO state machine is neo_prog
  • The frequency of the PIO state machine is set to 20MHz
  • The PIO state machine's pins is just the IN-pin of the chain NeoPixels

The MicroPython function ShowNeoPixels() is the "caller" of the PIO program, which is expected to put the above-mentioned data to the FIFO of the state machine.

def ShowNeoPixels(*pixels):
    '''
    each pixel is the tuple (r, g, b)
    '''
...

The rest is the main MicroPython program.

Step 3: Step 3: UI for the NeoPixels Experiment, With DumbDisplay

As mentioned previously, I will also be showing a UI for the experiment using the virtual display DumbDisplay realized wth your Android phone remotely. Hence, you will need the DumbDisplay MicroPython library, for which I have described a way to install to your Raspberry Pi Pico W with my previous post -- Respberry Pi Pico W Generating Tones With Programmable I/O (PIO) Using MicroPython

Assuming you have the DumbDisplay MicroPython library copied to your machine, you will find the MicroPython program bundled with the DumbDisplay MicroPython library -- MicroPython-DumbDisplay/samples/neopixels/main.py

The first two lines of the MicroPython define the number and the IN-pin of the NeoPixels. You may need to change them according to your actual setup

NUM_PIXELS = 4
NEO_PIXELS_IN_PIN = 22

Also, if you choose not to use PIO, you can do so by changing the following "raise exception" condition to True.

    # set to True if don't want to use PIO
    if False:
        raise Exception("I don't want to use PIO")

The MicroPython program should work with other microcontroller boards with WIFI support like ESP32. But of course, only RP2040 microcontroller like Raspberry Pi Pico W is expected to support PIO to drive the NeoPixels; for other boards, the standard NeoPixels APIs will be used in place of PIO.

Step 4: The UI

Hopefully, the UI should be somewhat self-explanatory.

  • At the bottom are three sliders for selecting a color RGB
  • The middle canvas shows the selected color, as well as the HEX values of the selected RGB
  • You can also drag inside the middle canvas to have R and G varied.
  • If "Auto Advance" is selected, the color selection is set to the 1st NeoPixel, after advancing all colors to the next NeoPixels, automatically every 200ms.
  • If "Auto Advance" is not selected, you will have to manually click the ">>>" button to have the color selection set to the 1st NeoPixel.

Step 5: Enjoy!

It is especially fun to see the NeoPixels change colors as you drag on the middle canvas.

Enjoy!

Peace be with you! May God bless you! Jesus loves you! Amazing Grace!