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.
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).
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)
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.
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 DesignTo 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
The IP core will be state machine based the state diagram for the state machine can be seen below
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 OverlayCreating 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",
)
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:
Once this has downloaded, we will see new notebooks.
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.
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.
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 FaceUpdating 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.
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.
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.
Comments