If you are building Raspberry Pi screen projects that require a compact, low-power display, the 3.5-inch SPI TFT LCD is a staple. However, legacy tutorials often point you toward the fbtft kernel overlay—a method that is notoriously fragile and largely broken on modern 64-bit Raspberry Pi OS (Bookworm). The modern, reliable approach is to bypass the kernel framebuffer entirely and drive the display from user-space using the luma.lcd Python library.
This guide walks through building a real-time system monitor using a Raspberry Pi 4 Model B (2GB) and a Waveshare 3.5" SPI TFT (ILI9486 controller). We will cover the exact wiring, provide a complete, error-handled Python script, and debug the exact SPI errors that leave most builders staring at a blank white screen.
Hardware Spec Sheet & Display Interface Comparison
Before wiring anything, it is critical to understand the trade-offs of the SPI interface compared to other Raspberry Pi display options. SPI is CPU-light and uses minimal pins, but it sacrifices refresh rate.
| Interface | Pins Used | Max Refresh Rate | CPU Overhead | Best Use Case | Avg. Cost (USD) |
|---|---|---|---|---|---|
| SPI (Serial) | 6-8 (MOSI, SCLK, CE, DC, RST, BL) | 15-30 FPS | Low (User-space) | Dashboards, stat monitors, menus | $22 - $35 |
| DSI (Display Serial) | 15-pin FPC ribbon | 60 FPS | Hardware driven | Touchscreens, media playback, GUI | $45 - $70 |
| DPI (Parallel RGB) | 20-28 (GPIO 0-27) | 60 FPS | Hardware driven | Custom PCBs, high-res retro gaming | $30 - $50 |
| HDMI | 19-pin dedicated port | 60-120 FPS | Hardware driven | Desktop replacement, full media centers | $60+ |
Project Parts List
- Compute: Raspberry Pi 4 Model B (2GB RAM) — The 2GB variant is sufficient for headless Python dashboard rendering.
- Display: Waveshare 3.5" RPi LCD (A) or generic ILI9486 SPI TFT (320x480 resolution).
- Storage: 16GB SanDisk Extreme microSD (Class 10, A1 rated for OS longevity).
- Power: Official Raspberry Pi 27W USB-C Power Supply (5.1V / 5A) — Under-voltage will cause SPI bus drops.
- Wiring: Female-to-female jumper wires (if not using a direct GPIO hat mount).
Wiring & Pin Mapping
The Waveshare 3.5" SPI screen requires hardware SPI0 for acceptable performance, plus three control GPIO pins. Do not use software (bit-banged) SPI for a 320x480 display; the CPU overhead will starve your system monitor script.
The Raspberry Pi 4 GPIO operates at 3.3V. The ILI9486 controller on these SPI boards is generally 3.3V tolerant on data lines, but the backlight (BL) pin often requires a 5V source to reach full brightness. We will wire BL to 5V (Pin 2) in this build, bypassing GPIO software dimming to guarantee maximum luminosity.
| LCD Pin Label | Pi GPIO / Pin | Function | Notes |
|---|---|---|---|
| VCC | Pin 1 (3.3V) | Logic Power | Do NOT connect to 5V |
| GND | Pin 6 (GND) | Ground | Common ground required |
| CS (or CE) | Pin 24 (GPIO 8 / CE0) | Chip Select | Hardware SPI0 Chip Select |
| RESET | Pin 18 (GPIO 24) | Hardware Reset | Active LOW |
| DC (or RS) | Pin 22 (GPIO 25) | Data/Command | High = Data, Low = Command |
| SDA (or MOSI) | Pin 19 (GPIO 10 / MOSI) | Master Out Slave In | Hardware SPI0 Data |
| SCK (or SCLK) | Pin 23 (GPIO 11 / SCLK) | Serial Clock | Hardware SPI0 Clock |
| LED (or BL) | Pin 2 (5V) | Backlight Power | Connected to 5V for max brightness |
Python System Monitor Code
This script targets Raspberry Pi OS Bookworm (64-bit). Bookworm replaced RPi.GPIO with lgpio under the hood, but the rpi-lgpio compatibility package allows legacy code to run. We use luma.lcd for the display driver, psutil for system stats, and Pillow for rendering.
Prerequisites
Install the required system packages and Python libraries via terminal:
sudo apt update
sudo apt install python3-pip python3-dev libjpeg-dev libfreetype6-dev
sudo pip3 install --break-system-packages luma.lcd psutil Pillow rpi-lgpio
Complete Python Script (sysmon.py)
#!/usr/bin/env python3
"""
Raspberry Pi SPI TFT System Monitor
Target: Raspberry Pi 4 Model B + Waveshare 3.5" ILI9486 SPI
"""
import time
import psutil
from PIL import ImageFont, ImageDraw
from luma.core.interface.serial import spi
from luma.core.render import canvas
from luma.lcd.device import ili9486
import socket
# --- PIN DEFINITIONS ---
SPI_PORT = 0
SPI_DEVICE = 0
DC_PIN = 25
RST_PIN = 24
# Backlight is hardwired to 5V, so we do not define a GPIO_LIGHT pin here.
def get_ip_address():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "No Network"
def main():
# 1. Initialize Hardware SPI Interface
try:
serial = spi(port=SPI_PORT, device=SPI_DEVICE,
gpio_DC=DC_PIN, gpio_RST=RST_PIN,
speed_hz=32000000)
# 2. Initialize ILI9486 Device (320x480)
device = ili9486(serial, width=320, height=480, rotate=2)
print(f"Display initialized: {device.width}x{device.height}")
except FileNotFoundError as e:
print(f"FATAL: SPI device not found. Check /boot/firmware/config.txt. Error: {e}")
return
except PermissionError as e:
print(f"FATAL: Permission denied on SPI bus. Add user to 'spi' group. Error: {e}")
return
except Exception as e:
print(f"FATAL: Display initialization failed: {e}")
return
# Load fonts (Fallback to default if custom fonts are missing)
try:
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 28)
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
except IOError:
font_large = ImageFont.load_default()
font_small = ImageFont.load_default()
ip_addr = get_ip_address()
try:
while True:
# Gather System Stats
cpu_pct = psutil.cpu_percent(interval=0.5)
mem = psutil.virtual_memory()
temps = psutil.sensors_temperatures()
# Extract CPU Temp (Safe extraction for Pi 4)
cpu_temp = 0.0
if 'cpu_thermal' in temps:
cpu_temp = temps['cpu_thermal'][0].current
elif 'cpu-thermal' in temps:
cpu_temp = temps['cpu-thermal'][0].current
# Render to Canvas
with canvas(device) as draw:
# Background is implicitly black (0,0,0)
# Header
draw.text((10, 10), "PI SYSTEM MONITOR", font=font_large, fill="cyan")
draw.text((10, 45), f"IP: {ip_addr}", font=font_small, fill="white")
# CPU Stats
draw.text((10, 90), "CPU Load:", font=font_small, fill="gray")
cpu_color = "green" if cpu_pct < 70 else "red"
draw.text((10, 115), f"{cpu_pct}%", font=font_large, fill=cpu_color)
# Temp Stats
draw.text((160, 90), "Temp:", font=font_small, fill="gray")
temp_color = "green" if cpu_temp < 65 else "orange"
draw.text((160, 115), f"{cpu_temp:.1f}C", font=font_large, fill=temp_color)
# Memory Stats
draw.text((10, 180), "RAM Usage:", font=font_small, fill="gray")
draw.text((10, 205), f"{mem.percent}% ({mem.used // (1024**2)} MB)", font=font_small, fill="white")
# Storage Stats
disk = psutil.disk_usage('/')
draw.text((10, 250), "Disk /:", font=font_small, fill="gray")
draw.text((10, 275), f"{disk.percent}% Used", font=font_small, fill="white")
# Refresh rate control (approx 1 FPS to save CPU)
time.sleep(0.5)
except KeyboardInterrupt:
print("\nMonitor stopped by user.")
device.cleanup()
except Exception as e:
print(f"Runtime error in main loop: {e}")
device.cleanup()
if __name__ == "__main__":
main()
Debugging Blank Screens & SPI Errors
When working with Raspberry Pi screen projects, a blank white or black screen is the most common failure mode. If your script fails, check these three things first before replacing hardware.
The First Three Things to Check
- Is SPI enabled in the firmware? On Raspberry Pi OS Bookworm, the config file moved. You must edit
/boot/firmware/config.txt(not/boot/config.txt) and ensuredtparam=spi=onis present and uncommented. - Is the user in the SPI group? If you aren't running as root, your user needs SPI bus permissions. Run
sudo usermod -a -G spi $USERand reboot. - Is the ribbon cable fully seated? The FPC connector on cheap SPI TFTs has a fragile flip-up latch. If the cable is inserted 1mm off-center, MISO/MOSI will cross, resulting in garbage data or a blank screen.
Exact Error Strings & Ranked Causes
OSError: [Errno 2] No such file or directory: '/dev/spidev0.0'
Meaning: The Linux kernel is not loading the SPI device tree overlay.
- Cause A (Most Likely):
dtparam=spi=onis missing from/boot/firmware/config.txt. - Cause B: You are running a virtual environment or Docker container without passing the
--device /dev/spidev0.0flag.
PermissionError: [Errno 13] Permission denied: '/dev/spidev0.0'
Meaning: The SPI bus is loaded, but your current user lacks read/write permissions.
- Cause A (Most Likely): User is not in the
spiorgpiogroups. Fix:sudo usermod -a -G spi,gpio $USERthen log out and back in. - Cause B: Udev rules for SPI haven't reloaded. Fix:
sudo udevadm control --reload-rules && sudo udevadm trigger.
RuntimeError: Cannot determine SOC peripheral base address
Meaning: The GPIO library cannot map the Pi's memory addresses, usually due to an OS/library mismatch.
- Cause A (Most Likely): You are on 64-bit Bookworm but installed the legacy
RPi.GPIOvia pip without therpi-lgpiocompatibility shim. Installrpi-lgpioto fix this. - Cause B: Running on a non-Raspberry Pi board (like an Orange Pi) where the BCM pin numbering scheme doesn't exist.
Extending and Simplifying the Build
Once your baseline system monitor is running, you will likely want to adapt it to your specific environment. Here is how to scale the project up or down.
How to Simplify: Switch to DSI
If the 15 FPS refresh rate of SPI is too sluggish, or if you are tired of managing Python rendering loops, simplify the build by switching to a DSI display (like the Official Raspberry Pi 7" Touchscreen). DSI acts as a native HDMI monitor. You simply plug in the 15-pin FPC ribbon, boot the Pi, and it outputs a standard desktop. You can then run a full-screen Chromium browser in kiosk mode pointing to a Grafana dashboard, entirely eliminating the need for Python canvas drawing.
How to Extend: Add MQTT Smart Home Stats
To turn this from a local monitor into a smart home node, extend the Python script by integrating paho-mqtt. Instead of just reading psutil, subscribe to an MQTT broker (like Home Assistant's Mosquitto) to display external sensor data.
# Extension Snippet: Add to your imports and main loop
import paho.mqtt.client as mqtt
outdoor_temp = "--"
def on_message(client, userdata, msg):
global outdoor_temp
if msg.topic == "homeassistant/sensor/outdoor_temp/state":
outdoor_temp = msg.payload.decode()
client = mqtt.Client()
client.on_message = on_message
client.connect("192.168.1.100", 1883, 60)
client.subscribe("homeassistant/sensor/outdoor_temp/state")
client.loop_start() # Runs in background thread
# Inside your canvas drawing loop:
draw.text((10, 320), f"Outside: {outdoor_temp}°C", font=font_small, fill="yellow")
By leveraging the luma.lcd library and hardware SPI, you bypass the most common pitfalls of Raspberry Pi screen projects. Ensure your firmware config is correct, verify your pin mappings against the physical board silkscreen, and your ILI9486 dashboard will run reliably for years.






