WS2812B LED Wall Display
Time: 120 min | Prerequisites: SSH Login, Serial Bus Interfaces
Build a low-resolution LED wall display using WS2812B addressable LED panels, driven by a Raspberry Pi 4 (rendering) and a Pico 2 (real-time LED output). This tutorial teaches embedded architecture split: Linux handles the complex UI/rendering, while a microcontroller handles the hard real-time protocol.
Architecture
Pi 4 (Linux) UART 2 Mbaud Pico 2 (MicroPython)
┌──────────────┐ TX ─────────────────► RX ┌──────────────────┐
│ Python / │ RX ◄───────────────── TX │ NeoPixel PIO │
│ SDL2 / │ GND ────────────────── GND │ parallel output │
│ framebuffer │ │ │──► Lane 0
│ renderer │ │ Serpentine │──► Lane 1
│ │ │ mapping │──► ...
│ send_frame() │ │ │──► Lane 7
└──────────────┘ └──────────────────┘
│ │ │ │
┌──────┴─┴─┴─┴─────┐
│ WS2812B Panels │
│ 64 × 32 = 2048 │
│ addressable LEDs │
└──────────────────┘
Why two processors?
| Requirement | Pi 4 (Linux) | Pico 2 (MicroPython) |
|---|---|---|
| Rendering | SDL2, OpenCV, Python — rich ecosystem | Limited graphics capability |
| Real-time timing | Cannot guarantee 800 kHz ±150ns | PIO hardware: cycle-accurate, zero jitter |
| Network/UI | WiFi, SSH, web server — easy | No networking |
WS2812B requires a precisely timed 800 kHz one-wire protocol. Each bit is a pulse: 0.4µs HIGH + 0.85µs LOW for "0", or 0.8µs HIGH + 0.45µs LOW for "1". Linux's scheduler cannot guarantee this timing — a context switch during transmission corrupts the entire frame. The Pico 2's PIO (Programmable I/O) hardware generates these pulses with zero CPU involvement and zero jitter.
Why Not SPI for Pi↔Pico?
MicroPython's machine.SPI is master-only — it cannot act as an SPI slave to receive data from the Pi. UART works in both directions, is simple to configure, and at 2 Mbaud provides ~200 KB/s throughput — more than enough for 64×32 at 30 fps (6 KB/frame).
For higher-bandwidth designs (128×64+), consider USB serial (~1 MB/s) or a C SDK firmware with SPI slave via PIO.
1. Hardware Setup
1.1 Panel Options
WS2812B panels come in various sizes. Pick based on your target resolution:
| Module | LEDs | Typical size | Notes |
|---|---|---|---|
| 8×8 | 64 | 65×65 mm | Cheap, widely available |
| 16×16 | 256 | 160×160 mm | Good building block |
| 8×32 | 256 | 65×320 mm | Strip format, easy to tile |
| 32×8 flexible | 256 | 320×80 mm | Bendable, good for curves |
Reference build: 64×32 display from 8× panels of 8×32, or 2× panels of 16×16 arranged in a 4×2 grid.
1.2 Target Sizes and Frame Rates
Frame time is determined by the WS2812 protocol: 30 µs per LED per lane, plus a 280 µs reset/latch.
| Size | LEDs | Lanes | LEDs/lane | Frame time | Max FPS | Use case |
|---|---|---|---|---|---|---|
| 32×16 | 512 | 4 | 128 | 4.1 ms | 240 | Small status display |
| 64×32 | 2048 | 8 | 256 | 7.9 ms | 126 | Good first build |
| 96×48 | 4608 | 8 | 576 | 17.6 ms | 57 | Large dashboard |
| 128×64 | 8192 | 16 | 512 | 15.6 ms | 64 | LED wall (advanced) |
Warning
These FPS numbers assume parallel lane output. With MicroPython's sequential neopixel.write() calls, multiply the frame time by the number of lanes. For 8 lanes: 7.9ms × 8 = 63ms = ~16 fps. Still usable for animations, and a custom parallel PIO program (Challenge 5) can fix it.
1.3 Power
WS2812B draws up to 60 mA per LED at full white (20 mA per colour channel). For 2048 LEDs:
Maximum: 2048 × 60 mA = 123 A at 5V = 615 W (theoretical, never happens)
Typical: 2048 × 15 mA = 30 A at 5V = 150 W (mixed colours, ~25% brightness)
Safe PSU: 5V 40A (200W) with brightness cap in software
Warning
Power injection is critical. Do NOT power 2048 LEDs from one end — the 5V rail drops along the chain, causing colour shifts and dim LEDs at the far end. Inject 5V+GND every 256 LEDs (every lane endpoint). Use thick wires (≥18 AWG) for power runs.
1.4 Level Shifting
Pico 2 GPIO outputs 3.3V. WS2812B expects 5V logic (the datasheet says VIH ≥ 0.7 × VDD = 3.5V at 5V supply). While 3.3V sometimes works (many chips accept it), it's unreliable — especially with long wires or noisy power.
Use a level shifter: 74HCT245 (8-channel, bidirectional) or SN74AHCT125 (4-channel). Both accept 3.3V input and output 5V. One 74HCT245 handles all 8 lanes.
Pico GP2 ──► 74HCT245 A0 ──► B0 ──► Lane 0 (WS2812 data in)
Pico GP3 ──► 74HCT245 A1 ──► B1 ──► Lane 1
...
Pico GP9 ──► 74HCT245 A7 ──► B7 ──► Lane 7
74HCT245 VCC = 5V, GND = GND, DIR = VCC (A→B), OE = GND (enabled)
1.5 Wiring
Raspberry Pi 4 Pico 2 LED Panels
┌─────────────┐ ┌──────────────┐
│ GPIO14 (TX) ├─────────►│ GP1 (UART RX)│
│ GPIO15 (RX) │◄─────────┤ GP0 (UART TX)│
│ GND ├──────────┤ GND │
└─────────────┘ │ │ ┌──────────────┐
│ GP2 ─────────┼──[LVL]──┤ Lane 0 DIN │
│ GP3 ─────────┼──[LVL]──┤ Lane 1 DIN │
│ GP4 ─────────┼──[LVL]──┤ Lane 2 DIN │
│ GP5 ─────────┼──[LVL]──┤ Lane 3 DIN │
│ GP6 ─────────┼──[LVL]──┤ Lane 4 DIN │
│ GP7 ─────────┼──[LVL]──┤ Lane 5 DIN │
│ GP8 ─────────┼──[LVL]──┤ Lane 6 DIN │
│ GP9 ─────────┼──[LVL]──┤ Lane 7 DIN │
└──────────────┘ [LVL] = 74HCT245
└──────────────┘
5V PSU ──► Panel VCC (inject every 256 LEDs)
GND ──► Panel GND + Pico GND + Pi GND
Warning
All GNDs must be connected together: Pi GND, Pico GND, level shifter GND, LED panel GND, and PSU GND. Without a common ground, UART and level shifting will not work.
2. Pico 2 Firmware (MicroPython)
2.1 Single-Lane Test
Start with one NeoPixel chain to verify wiring and colour order:
# test_single.py — verify one LED chain
import neopixel, machine, time
NUM_LEDS = 256
pin = machine.Pin(2)
np = neopixel.NeoPixel(pin, NUM_LEDS)
# WS2812 uses GRB colour order internally, but MicroPython's
# neopixel module handles the conversion — pass (R, G, B).
print(f"Testing {NUM_LEDS} LEDs on pin {pin}")
# Red sweep
for i in range(NUM_LEDS):
np[i] = (255, 0, 0)
np.write()
time.sleep_ms(10)
# All green
for i in range(NUM_LEDS):
np[i] = (0, 255, 0)
np.write()
time.sleep(1)
# All blue
for i in range(NUM_LEDS):
np[i] = (0, 0, 255)
np.write()
time.sleep(1)
print("Test complete")
If you see green when you expect red (or vice versa), the colour order is wrong. Some WS2812 clones use RGB instead of GRB. In MicroPython, NeoPixel takes a bpp parameter — try NeoPixel(pin, n, bpp=3) and swap channels manually if needed.
2.2 Serpentine Mapping
LED panels are wired in a serpentine (zigzag) pattern — even rows go left-to-right, odd rows go right-to-left:
Physical wiring order within one lane:
Row 0: → LED 0 LED 1 LED 2 LED 3 ... LED 63
Row 1: ← LED 127 LED 126 LED 125 LED 124 ... LED 64
Row 2: → LED 128 LED 129 LED 130 LED 131 ... LED 191
Row 3: ← LED 255 LED 254 LED 253 LED 252 ... LED 192
Logical (x, y) → physical LED index:
if y is even: index = y × WIDTH + x (left to right)
if y is odd: index = y × WIDTH + (WIDTH - 1 - x) (right to left)
def serpentine_index(x, y, width):
"""Convert (x, y) to physical LED index in a serpentine chain."""
if y % 2 == 0:
return y * width + x
else:
return y * width + (width - 1 - x)
Tip
Not all panels use the same serpentine direction. Some start right-to-left on row 0. Test with the single-lane script: light up LED index 0 and see which physical LED illuminates. If it's the top-right corner instead of top-left, swap the even/odd logic.
2.3 Multi-Lane Mapping
For a 64×32 display with 8 lanes, each lane drives a horizontal strip of 4 rows:
Full 64×32 display:
Lane 0: rows 0– 3 (256 LEDs)
Lane 1: rows 4– 7 (256 LEDs)
Lane 2: rows 8–11 (256 LEDs)
Lane 3: rows 12–15 (256 LEDs)
Lane 4: rows 16–19 (256 LEDs)
Lane 5: rows 20–23 (256 LEDs)
Lane 6: rows 24–27 (256 LEDs)
Lane 7: rows 28–31 (256 LEDs)
def pixel_to_lane(x, y, width=64, rows_per_lane=4):
"""Map (x, y) to (lane_index, led_index_in_lane)."""
lane = y // rows_per_lane
local_y = y % rows_per_lane
led_idx = serpentine_index(x, local_y, width)
return lane, led_idx
2.4 Full Firmware
# led_wall.py — Pico 2 LED wall controller (MicroPython)
#
# Receives RGB frames via UART from the Pi, maps pixels to
# serpentine panel layout, outputs via NeoPixel PIO.
#
# Upload to Pico 2 and rename to main.py for auto-start.
import machine, neopixel, time
# ── Configuration ────────────────────────────────────────
WIDTH = 64
HEIGHT = 32
ROWS_PER_LANE = 4
N_LANES = HEIGHT // ROWS_PER_LANE # 8
LEDS_PER_LANE = WIDTH * ROWS_PER_LANE # 256
FRAME_SIZE = WIDTH * HEIGHT * 3 # 6144 bytes
BRIGHTNESS = 0.3 # 0.0–1.0, limit power draw
LANE_PINS = [2, 3, 4, 5, 6, 7, 8, 9]
UART_BAUD = 2_000_000
# ── Hardware init ────────────────────────────────────────
uart = machine.UART(0, baudrate=UART_BAUD,
tx=machine.Pin(0), rx=machine.Pin(1),
rxbuf=FRAME_SIZE + 256)
lanes = [neopixel.NeoPixel(machine.Pin(p), LEDS_PER_LANE)
for p in LANE_PINS]
# ── Pre-compute mapping table ────────────────────────────
# mapping[pixel_index] = (lane, led_index)
mapping = []
for y in range(HEIGHT):
for x in range(WIDTH):
lane = y // ROWS_PER_LANE
local_y = y % ROWS_PER_LANE
if local_y % 2 == 0:
idx = local_y * WIDTH + x
else:
idx = local_y * WIDTH + (WIDTH - 1 - x)
mapping.append((lane, idx))
# ── Frame protocol ───────────────────────────────────────
# Header: 0xFF 0x00 width_hi width_lo height_hi height_lo
# Payload: WIDTH × HEIGHT × 3 bytes (RGB, row-major)
HEADER = b'\xff\x00'
def read_exact(n):
"""Read exactly n bytes from UART, blocking."""
buf = bytearray(n)
pos = 0
while pos < n:
chunk = uart.read(min(512, n - pos))
if chunk:
buf[pos:pos + len(chunk)] = chunk
pos += len(chunk)
return buf
def receive_frame():
"""Wait for and receive one frame."""
# Sync to header
while True:
b = uart.read(1)
if b is None:
continue
if b[0] == 0xFF:
b2 = uart.read(1)
if b2 and b2[0] == 0x00:
dims = read_exact(4)
w = (dims[0] << 8) | dims[1]
h = (dims[2] << 8) | dims[3]
if w == WIDTH and h == HEIGHT:
return read_exact(FRAME_SIZE)
def apply_frame(data):
"""Map RGB data to lane NeoPixel buffers."""
br = BRIGHTNESS
for i, (lane, idx) in enumerate(mapping):
off = i * 3
r = int(data[off] * br)
g = int(data[off + 1] * br)
b = int(data[off + 2] * br)
lanes[lane][idx] = (r, g, b)
def flush():
"""Write all lanes to LEDs."""
for lane in lanes:
lane.write()
# ── Startup pattern ──────────────────────────────────────
print(f"LED Wall: {WIDTH}x{HEIGHT}, {N_LANES} lanes, "
f"{LEDS_PER_LANE} LEDs/lane")
print(f"UART: {UART_BAUD} baud, frame: {FRAME_SIZE} bytes")
print("Waiting for frames...")
# Show a dim test pattern on startup
for lane in lanes:
for i in range(LEDS_PER_LANE):
lane[i] = (1, 1, 1) # dim white
lane.write()
# ── Main loop ────────────────────────────────────────────
frame_count = 0
t0 = time.ticks_ms()
while True:
data = receive_frame()
apply_frame(data)
flush()
frame_count += 1
elapsed = time.ticks_diff(time.ticks_ms(), t0)
if elapsed >= 5000:
fps = frame_count * 1000 / elapsed
print(f"{fps:.1f} fps")
frame_count = 0
t0 = time.ticks_ms()
3. Pi-Side: Frame Sender
3.1 Enable UART
The Pi's UART is on GPIO14 (TX) and GPIO15 (RX). Enable it:
# Add to /boot/firmware/config.txt:
enable_uart=1
dtoverlay=disable-bt # free up the full UART (otherwise Bluetooth uses it)
# Reboot
sudo reboot
# Verify
ls -l /dev/serial0 # should point to /dev/ttyAMA0
Install pyserial:
3.2 Rainbow Test
#!/usr/bin/env python3
"""send_frames.py — stream animated frames to the LED wall."""
import serial, struct, time, math, colorsys
WIDTH, HEIGHT = 64, 32
FRAME_SIZE = WIDTH * HEIGHT * 3
ser = serial.Serial('/dev/serial0', 2_000_000, timeout=0.1)
def send_frame(pixels: bytes):
"""Send one RGB frame to the Pico."""
header = b'\xff\x00'
dims = struct.pack('>HH', WIDTH, HEIGHT)
ser.write(header + dims + pixels)
def rainbow(t):
"""Generate a scrolling rainbow frame."""
data = bytearray(FRAME_SIZE)
for y in range(HEIGHT):
for x in range(WIDTH):
hue = (x / WIDTH + y / HEIGHT * 0.5 + t) % 1.0
r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
off = (y * WIDTH + x) * 3
data[off] = int(r * 255)
data[off + 1] = int(g * 255)
data[off + 2] = int(b * 255)
return bytes(data)
print(f"Streaming {WIDTH}×{HEIGHT} rainbow @ 2 Mbaud")
count, t0 = 0, time.time()
while True:
send_frame(rainbow(time.time() * 0.3))
count += 1
if time.time() - t0 >= 2.0:
print(f"{count / 2:.0f} fps")
count, t0 = 0, time.time()
3.3 Framebuffer Capture
Mirror what's on the Pi's display to the LED wall — downscale from 800×480 to 64×32:
#!/usr/bin/env python3
"""fb_to_wall.py — capture the Pi's display and send to LED wall."""
import mmap, serial, struct, time
WIDTH, HEIGHT = 64, 32
FB_W, FB_H = 800, 480 # Pi display resolution
BPP = 4 # ARGB8888
fb = open('/dev/fb0', 'rb')
mm = mmap.mmap(fb.fileno(), FB_W * FB_H * BPP, access=mmap.ACCESS_READ)
ser = serial.Serial('/dev/serial0', 2_000_000)
def capture_and_scale():
"""Nearest-neighbour downscale from framebuffer to LED resolution."""
data = bytearray(WIDTH * HEIGHT * 3)
for y in range(HEIGHT):
for x in range(WIDTH):
fb_x = x * FB_W // WIDTH
fb_y = y * FB_H // HEIGHT
off = (fb_y * FB_W + fb_x) * BPP
# BGRA → RGB
b, g, r = mm[off], mm[off + 1], mm[off + 2]
led_off = (y * WIDTH + x) * 3
data[led_off] = r
data[led_off + 1] = g
data[led_off + 2] = b
return bytes(data)
def send_frame(pixels):
ser.write(b'\xff\x00' + struct.pack('>HH', WIDTH, HEIGHT) + pixels)
print(f"Mirroring /dev/fb0 ({FB_W}×{FB_H}) → LED wall ({WIDTH}×{HEIGHT})")
while True:
send_frame(capture_and_scale())
time.sleep(1 / 30) # cap at 30 fps
Note
This captures whatever is on the framebuffer — SDL2 apps, the console, even the audio visualizer. Run audio_viz_full on the HDMI display and fb_to_wall.py simultaneously to see the spectrum on the LED wall.
4. Performance and Debugging
4.1 Bandwidth Budget
UART throughput at 2 Mbaud (8N1):
Raw: 2,000,000 / 10 bits = 200,000 bytes/s
Frame: 6 bytes header + 6144 bytes payload = 6150 bytes
Max FPS: 200,000 / 6150 ≈ 32 fps
WS2812 output (8 lanes, sequential write):
Per lane: 256 × 30 µs = 7.68 ms
All 8 lanes (sequential): 8 × 7.68 = 61.4 ms
Max FPS: 1000 / 61.4 ≈ 16 fps ← this is the bottleneck
The sequential neopixel.write() limits throughput. The UART can deliver 32 fps but the LED output can only consume 16 fps. See Challenge 5 for a parallel PIO solution.
4.2 Common Issues
| Symptom | Cause | Fix |
|---|---|---|
| No LEDs light up | Level shifter not powered, GND not connected | Check 5V on 74HCT245 VCC, all GNDs joined |
| First few LEDs correct, rest garbled | Signal integrity, long wires | Add 100Ω series resistor on data line, shorten wires |
| Colours wrong (R↔G swapped) | WS2812 variant with different colour order | Try swapping R and G in apply_frame() |
| Last LEDs dim or wrong colour | Voltage drop along chain | Inject 5V power at multiple points |
| Flickering at high FPS | UART buffer overrun on Pico | Increase rxbuf size, reduce Pi send rate |
| Frame tearing (partial updates) | Frame sync lost | Check header bytes, add CRC for reliability |
4.3 GRB Colour Order
Warning
WS2812B internally uses GRB colour order, not RGB. MicroPython's neopixel module handles this conversion — you pass (R, G, B) tuples and it sends GRB on the wire. If you write a custom PIO program or use C, you must swap the bytes yourself.
5. Advanced: Custom DRM Driver (Optional)
For advanced users who want the LED wall to appear as a real Linux display (/dev/dri/cardN):
The kernel's drivers/gpu/drm/tiny/ directory has small DRM drivers that serve as templates. A minimal LED wall driver would:
- Use
drm_simple_display_pipe_funcsfor a single-plane, single-CRTC setup - Expose one fixed mode (e.g., 64×32 @ 16 Hz)
- On atomic update: read the shadow framebuffer, package as UART frame, send to Pico
This is ~300 lines of kernel C. The benefit: any Linux app that outputs to a display (console, SDL2, Qt) works on the LED wall without modification. The cost: kernel driver development and maintenance.
Recommendation: Start with the userspace approach (Sections 2–3). Only consider a DRM driver if you need the wall to be a first-class Linux monitor.
Challenges
Tip
Challenge 1: Text Scroller
Render scrolling text on the LED wall. Use Python's PIL (Pillow) to render text to a bitmap, then scroll it horizontally. Implement variable speed and colour.
Tip
Challenge 2: Audio Visualizer Mirror
Run audio_viz_full on the Pi's HDMI display. Simultaneously, capture the framebuffer with fb_to_wall.py and show the spectrum on the LED wall. How does the low resolution affect readability?
Tip
Challenge 3: Conway's Game of Life Implement the Game of Life directly on the Pi, send frames to the wall. At 64×32, the patterns are clearly visible. Add touch/mouse interaction to toggle cells.
Tip
Challenge 4: Camera Preview
Capture the Pi camera with picamera2, downscale to 64×32, and stream to the LED wall. What's the minimum recognizable image at this resolution? Experiment with edge detection to improve recognizability.
Tip
Challenge 5: Parallel PIO Output
The sequential neopixel.write() is the FPS bottleneck. Write a custom PIO program in MicroPython (@rp2.asm_pio) that shifts out all 8 lanes simultaneously using a single state machine and bit-packed data. This should increase output FPS from 16 to 130. See the ws2812_parallel example in the Pico SDK for the PIO assembly pattern.
Tip
Challenge 6: DRM Display Driver
Write a minimal DRM driver (kernel module) that exposes the LED wall as /dev/dri/cardN. Use drm_simple_display_pipe. The driver's flush function packages the shadow buffer and sends via SPI or UART to the Pico. Test by running fbcon (console on the LED wall) or an SDL2 app.
See also: Serial Bus Interfaces | MCU RT Control | SDL2 UI Patterns | Framebuffer Basics