Introduction

NeoPixels are digitally controlled red, green and blue pixels. As each color is represented by 8 bits giving a 24-bit total, this enables each pixel to display one of 16,777,216 colors.

NeoPixel Color Format

Each NeoPixel is actually a WS2812 LED. These LEDs contain five inputs and operate from a voltage range of 3.3V to 5.0V (VDD & VCC) with respect to ground (VSS).

WS2812 LED

Inputs and outputs from the WS2812 LEDs occurs on the DI and DO pins which are the data input and data output lines.

As each pixel requires a 24-bit word to set the RGB pixel value, both the input and the outputs use a serial self clocking format. This saves on the requirement for a clock input as well.

To transfer data, the WS2812 pixel use a non-return to zero (NRTZ) waveform.

This waveform’s bit period and duty cycle both change depending on whether the bit value represented is a 1 or 0. The timings are: T0H = 0.35μsec, T0L = 0.8μsec, T1H = 0.7μsec, and T1L = 0.6μsec. All timing values have a tolerance of ± 150nsec. These times give slightly different duration for a Low bit (1.15μsec) compared to a High bit (1.3μsec)

Zero and one wavefroms

The observant will have noticed the 24-bit word that is applied to the NeoPixel does not contain and address field. Therefore, how does a NeoPixel know if the 24-bit value is used for itself or if it should pass on the word on its output?

How this is achieved is actually very simple, if after receiving a the 24-bit word, the NeoPixel does not receive another 24-bit word within 50 uS the value is latched in and displayed. If the NeoPixel does receive another word inside 50 uS the NeoPixel will output the previously received word.

NeoPixel output demonstrating three NeoPixels

This means we can drive potentially a infinite length of NeoPixels we are just limited by the power supply needed and the time an update takes.

For this exampl, we are going to create a NeoPixel cube which can be controlled via the PYNQ-Z2 as such we will need to create our own overlay.

To create a cube, we need six 64 NeoPixel panels which gives us a total of 384 NeoPixels.

IP Core Design

To generate the NeoPixel drive from our PYNQ environment we are going to need to generate a custom overlay. This starts with the creation of a programmable logic design which can be loaded into the programmable logic on a Zynq. To support a large number of NeoPixels I am going to take the following approach to the logic design architecture

NeoPixel IP Core - This will generate the timing for the NeoPixel and output the correct number of NeoPixel values.

BRAM - A dual port BRAM which enables the Zynq Processor System (PS) to store the calculated NeoPixel values. Second port can then be accessed by the NeoPixel IP Core.

BRAM Controller - Provides the interface between the Zynq PS and BRAM to enable read and write.

The BRAM will contain the NeoPixel drive values and the number of pixels in the string to make it adaptable for other applications

Overlay architecture in Vivado IP Integrator

The IP core will be state machine based the state diagram for the state machine can be seen below

IP core state machine

This state machine was implemented using the code below.

LIBRARY IEEE; USE ieee.std_logic_1164.ALL; USE ieee.numeric_std.ALL; ENTITY neo_pixel IS PORT( clk : IN std_logic; dout : OUT std_logic; rstb : OUT STD_LOGIC; enb : OUT STD_LOGIC; web : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); addrb : OUT STD_LOGIC_VECTOR(31 DOWNTO 0); dinb : OUT STD_LOGIC_VECTOR(31 DOWNTO 0); doutb : IN STD_LOGIC_VECTOR(31 DOWNTO 0) ); END ENTITY; ARCHITECTURE rtl OF neo_pixel IS TYPE FSM IS (idle,wait1,led,count,reset,addr_out,wait2,grab,wait_done,done_addr); CONSTANT done : std_logic_vector(25 DOWNTO 0) := "00000000000000000000000001"; --shows when shift reg empty CONSTANT zero : std_logic_vector(24 DOWNTO 0) := "1111111000000000000000000"; --waveform for a zero bit CONSTANT one : std_logic_vector(24 DOWNTO 0) := "1111111111111100000000000"; --waveform for a one bit CONSTANT numb_pixels : integer := 24; --number of bits in a pixel CONSTANT reset_duration : integer := 1000;--number cclocks in the reset period SIGNAL shift_reg : std_logic_vector(24 DOWNTO 0) := (OTHERS=>'0'); -- shift reg containing the output pixel waveform SIGNAL shift_dne : std_logic_vector(25 DOWNTO 0) := (OTHERS=>'0'); -- shift reg for timing the output shift reg for next load SIGNAL current_state : fsm := idle; --fsm to control the pixel beign output SIGNAL prev_state : fsm := idle; --previous state SIGNAL load_shr : std_logic :='0'; --loads the shr with the next pixel SIGNAL pix_cnt : integer RANGE 0 TO 31 := 0; --counts the position in the pixel to op SIGNAL rst_cnt : integer RANGE 0 TO 1023 := 0; --counts number of clocks in the reset period 50 us @ 20 MHz SIGNAL led_numb : integer RANGE 0 TO 1023; --number of LED in the string SIGNAL ram_addr : integer RANGE 0 TO 1023:=0; --address to read from RAM SIGNAL led_cnt : integer RANGE 0 TO 1023;--counts leds it has addressed SIGNAL pixel : std_logic_vector(23 DOWNTO 0); --holds led value to be output BEGIN web <= (OTHERS => '0'); rstb <= '0'; dinb <= (OTHERS =>'0'); pixel_cntrl : PROCESS(clk) BEGIN IF rising_edge(CLK) THEN load_shr <= '0'; enb <= '0'; CASE current_state IS WHEN idle => current_state <= wait1; rst_cnt <= 0; addrb <= std_logic_vector(to_unsigned(ram_addr,32)); enb<='1'; WHEN wait1 => current_state <= led; WHEN led => led_numb <= to_integer(unsigned(doutb)); IF to_integer(unsigned(doutb)) = 0 THEN current_state <= idle; ELSE current_state <= addr_out; ram_addr <= ram_addr +4; END IF; WHEN count => IF pix_cnt = (numb_pixels-1) THEN IF led_cnt = (led_numb-1) THEN current_state <= reset; pix_cnt <= 0; ram_addr <= 0; ELSE ram_addr <= ram_addr+4; current_state <= done_addr; led_cnt <= led_cnt + 1; END IF; ELSE current_state <= wait_done; END IF; WHEN done_addr => IF (shift_dne(shift_dne'high-1) = '1') THEN current_state <= addr_out; END IF; WHEN wait_done => IF (shift_dne(shift_dne'high-1) = '1') THEN load_shr <='1'; pix_cnt <= pix_cnt + 1; current_state <= count; END IF; WHEN addr_out => addrb <= std_logic_vector(to_unsigned(ram_addr,32)); enb<='1'; current_state <= wait2; WHEN wait2 => current_state <= grab; prev_state <= wait2; WHEN grab => pixel <= doutb(doutb'high-8 DOWNTO doutb'low); load_shr <= '1'; current_state <= wait_done; pix_cnt <=0; WHEN reset => pix_cnt <= 0; led_cnt <= 0; IF rst_cnt = (reset_duration-1) THEN current_state <= idle; ram_addr <= 0; ELSE rst_cnt <= rst_cnt + 1; END IF; END CASE; END IF; END PROCESS; shr_op : PROCESS(clk) BEGIN IF rising_edge(clk) THEN IF load_shr ='1' THEN shift_dne <= done; IF pixel((numb_pixels-1)-pix_cnt) = '1' THEN shift_reg <= one; ELSE shift_reg <= zero; END IF; ELSE shift_reg <= shift_reg(shift_reg'high-1 DOWNTO shift_reg'low) & '0'; shift_dne <= shift_dne(shift_dne'high-1 DOWNTO shift_reg'low) & '0'; END IF; END IF; END PROCESS; dout <= shift_reg(shift_reg'high); END ARCHITECTURE;

To achieve timing for the NeoPixel, the custom IP modules needs a 20 MHz clock, which is provided by the Processor System (PS) Fabric Clock 1.

Once the design has been implemented and we have a bit stream for the programmable logic, the next step is to create the Pynq Overlay. As we are changing the configuration of the processor e.g to use fabric clock 1 and at a frequency of 20 MHz, we need to be able to inform the PYNQ system of these updates. To do this along with the bitstream, we need to generate a TCL file which describes the overlay. We can do this in Vivado by issuing the command:

write_bd_tcl — force ovelay.tcl

This will be output into the current working directory - if you are unsure where this is current use the PWD and CD commands to select a directory you are working in.

Creating the PYNQ Overlay

Creating a PYNQ overlay once we have it is pretty simple we need:

bit and tcl files both with identical names

setup.py

Notebooks — An example notebook showing how it can be used

This will enable me to create a GitHub repo from which I can (and anyone else) can download and install the PYNQ-Z2 with the NeoPixel IP core.

We get everything we need to create this repo from Vivado as shown above however to install if correctly we need to create a setup.py file. This file:

from setuptools import setup, find_packages #import neo_pynq from distutils.dir_util import copy_tree import os import shutil # global variables board = os.environ['BOARD'] repo_board_folder = f'boards/{board}/neo_pixel' board_notebooks_dir = os.environ['PYNQ_JUPYTER_NOTEBOOKS'] hw_data_files = [] ovl_dest = 'neo_pixel' # check whether board is supported def check_env(): if not os.path.isdir(repo_board_folder): raise ValueError("Board {} is not supported.".format(board)) if not os.path.isdir(board_notebooks_dir): raise ValueError("Directory {} does not exist.".format(board_notebooks_dir)) # copy overlays to python package def copy_overlays(): src_ol_dir = os.path.join(repo_board_folder, 'bitstream') dst_ol_dir = os.path.join(ovl_dest, 'bitstream') copy_tree(src_ol_dir, dst_ol_dir) hw_data_files.extend([os.path.join("..", dst_ol_dir, f) for f in os.listdir(dst_ol_dir)]) # copy notebooks to jupyter home def copy_notebooks(): src_nb_dir = os.path.join(repo_board_folder, 'notebook') dst_nb_dir = os.path.join(board_notebooks_dir, 'neo_pixel') if os.path.exists(dst_nb_dir): shutil.rmtree(dst_nb_dir) copy_tree(src_nb_dir, dst_nb_dir) check_env() copy_overlays() copy_notebooks() setup( name= "neo_pixel", version= "1.1", url= 'https://github.com/ATaylorCEngFIET/pynq_neopixel.git', license = 'Apache Software License', author= "Adam Taylor", author_email= "adam@adiuvoengineering.com", packages= find_packages(), package_data= { '': hw_data_files, }, description= "Neo Pixel Driver for PYN1 Z2", )

NeoPixel Repo on GitHub

To install the NeoPixel Repo on our Pynq-Z2, we use the command below in a terminal window on the PYNQ-Z2.

sudo pip3 install — upgrade git+https://github.com/ATaylorCEngFIET/pynq_neopixel.git

This might take a few minutes to install:

Downloading in the terminal window

Once this has downloaded, we will see new notebooks.

Installed NeoPixel Overlay

Using the Overlay

Now we have the overlay we of course can use it for our NeoPixel cube and displays.

Of course, the first thing we want to do is ensure it works and that we can drive the first and last pixels on the display.

The code below downloads the NeoPixel overlay into the programmable logic.

import os print (os.getcwd()) from pynq import Overlay overlay = Overlay("/usr/local/lib/python3.6/dist-packages/neo_pixel/bitstream/neo_pixel.bit") overlay.download() overlay.timestamp

To be able to write to the Block RAM memory we can use MMIO, to use the MMIO we need to define a physical base address (available in Vivado) and the memory size. We can then write and read the block ram with ease.

from pynq import MMIO base_address = 0x40000000 mem_size = 2048 mmio = MMIO(base_address,mem_size) data = 0x12345678 address_offset = 0x4 mmio.write(address_offset,data) result = mmio.read(address_offset) print(hex(result))

To check that I have wired up the NeoPixels properly and connected them correctly to the PYNQ-Z2 we can use the code below.

Remember as we are addressing a 32 bit system, each NeoPixel address is offset by 0x04

Once we have configured the NeoPixl values we desire in the block RAM memory we can enable it by writing the BRAM address 0 the number of pixels in the string.

data = 0x0000000f address_offset = 0x4 mmio.write(address_offset,data) data = 0x1 address_offset = 0x0 mmio.write(address_offset,data)

Running this code enabled me to see the first LED illuminated and therefore the PYNQ -> PL -> Pmod -> NeoPixel Array is working correctly.

First Pixel Connected and Illuminated.

All of the NeoPixels are connected as a string that is NeoPixels 0-63 are on the first panel, 64-127 on the second and so on.

If we want to read back the setting of any LED in the NeoPixel array:

result = mmio.read(0x0) print(hex(result))

Flash the panels the same colors 10 times:

import time #base colour of blue address_offset = 0 for x in range(256): data = 0x0000000f address_offset = address_offset + 4 mmio.write(address_offset,data) #flash panel 1 address_offset = 0 for y in range(0,10): address_offset = 0 for x in range(0,256): data = 0x00000700 address_offset = address_offset + 4 mmio.write(address_offset,data) time.sleep( 1 ) address_offset = 0 for x in range(0,256): data = 0x00000707 address_offset = address_offset + 4 mmio.write(address_offset,data) time.sleep( 1 )

Once this was working on the NeoPixel cube, I captured the video below.

NeoPixel Panel Flashing

Now we have the cube all working creating additional patterns is very simple as everything is memory mapped into the BRAM.

To walk around simple LED illuminated on three of the panels at the time, we can use the following code:

address_offset = 0 for x in range(256): data = 0x0000000f address_offset = address_offset + 4 mmio.write(address_offset,data) #flash panel 1 address_offset = 0 for y in range(0,64): address_offset = 1 for x in range(0,1): data = 0x000F0000 address_offset = (y+x+(1)) * 4 mmio.write(address_offset,data) address_offset_2 = 260 +(y+x+(1)) * 4 mmio.write(address_offset_2,data) address_offset_3 = 516 +(y+x+(1)) * 4 mmio.write(address_offset_3,data) time.sleep( 0.2 ) address_offset = 0 for x in range(0,1): data = 0x0000000F address_offset = (y+x+1) * 4 mmio.write(address_offset,data) address_offset_2 = 260 +(y+x+(1)) * 4 mmio.write(address_offset_2,data) address_offset_3 = 516 +(y+x+(1)) * 4 mmio.write(address_offset_3,data) time.sleep(0.2 )

Watching this was pretty cool as the green dot flies around the cube.

Making a Bunny Face

Updating the pixels for display is pretty straight forward, all we need to do is use and 8 by 8 grid in a spreadsheet and fill in the cells as required to display a pattern. For example to create a bunny symbol we can use the following - with or without "evil" eyes.

Bunny Face Display

On the first panel the address are simply the pixel location multiplied by 4 due to the 32 bit access. We can then use the code below to draw this on the first panel, all of the other panels we repeat the code and update the offset for the panel.

#bunny address_offset = 0 for x in range(256): data = 0x00000000 address_offset = address_offset + 4 mmio.write(address_offset,data) colour = 0x00000004 base = 0 mmio.write(base+4,colour) mmio.write(base+8,colour) mmio.write(base+28,colour) mmio.write(base+32,colour) mmio.write(base+36,colour) mmio.write(base+40,colour) mmio.write(base+44,colour) mmio.write(base+56,colour) mmio.write(base+60,colour) mmio.write(base+64,colour) mmio.write(base+72,colour) mmio.write(base+76,colour) mmio.write(base+88,colour) mmio.write(base+92,colour) mmio.write(base+108,colour) mmio.write(base+120,colour) mmio.write(base+140,colour) mmio.write(base+144,colour) mmio.write(base+148,colour) mmio.write(base+152,colour) mmio.write(base+168,colour) mmio.write(base+172,colour) mmio.write(base+176,colour) mmio.write(base+180,colour) mmio.write(base+184,colour) mmio.write(base+188,colour) mmio.write(base+200,colour) mmio.write(base+204,colour) mmio.write(base+208,colour) mmio.write(base+212,colour) mmio.write(base+216,colour) mmio.write(base+220,colour) mmio.write(base+236,colour) mmio.write(base+240,colour) mmio.write(base+244,colour) mmio.write(base+248,colour)

We can easily change the color of the bunny face and panel location using the colour and base variables.

Bunny face on three panels at maximum brightness

Now I have shown how to work create the pixel cube, the NeoPixel overlay is available on my GitHub why not see what patterns you can display on your cube! I am keen to see.

See previous projects here.

Additional information on Xilinx FPGA / SoC development can be found weekly on MicroZed Chronicles.