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.

Table 1: Raspberry Pi Display Interface Comparison (2026 Standards)
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.

⚠️ Callout: Logic Level Warning
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.
Table 2: SPI TFT to Raspberry Pi 4 GPIO Pin Mapping
LCD Pin Label Pi GPIO / Pin Function Notes
VCCPin 1 (3.3V)Logic PowerDo NOT connect to 5V
GNDPin 6 (GND)GroundCommon ground required
CS (or CE)Pin 24 (GPIO 8 / CE0)Chip SelectHardware SPI0 Chip Select
RESETPin 18 (GPIO 24)Hardware ResetActive LOW
DC (or RS)Pin 22 (GPIO 25)Data/CommandHigh = Data, Low = Command
SDA (or MOSI)Pin 19 (GPIO 10 / MOSI)Master Out Slave InHardware SPI0 Data
SCK (or SCLK)Pin 23 (GPIO 11 / SCLK)Serial ClockHardware SPI0 Clock
LED (or BL)Pin 2 (5V)Backlight PowerConnected 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

  1. 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 ensure dtparam=spi=on is present and uncommented.
  2. 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 $USER and reboot.
  3. 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

🐛 Error 1: 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=on is missing from /boot/firmware/config.txt.
  • Cause B: You are running a virtual environment or Docker container without passing the --device /dev/spidev0.0 flag.
🐛 Error 2: 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 spi or gpio groups. Fix: sudo usermod -a -G spi,gpio $USER then log out and back in.
  • Cause B: Udev rules for SPI haven't reloaded. Fix: sudo udevadm control --reload-rules && sudo udevadm trigger.
🐛 Error 3: 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.GPIO via pip without the rpi-lgpio compatibility shim. Install rpi-lgpio to 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.