Setting up a touch display for Raspberry Pi using an SPI interface requires navigating kernel overlays, pin multiplexing, and input event mapping. Unlike plug-and-play HDMI monitors, SPI LCDs demand explicit configuration to bridge the gap between the display controller and the touch digitizer. This guide targets the Waveshare 3.5inch SPI Touch LCD (B) (featuring an ILI9486 display driver and XPT2046 touch controller) running on a Raspberry Pi 4 Model B (4GB) with Raspberry Pi OS Bookworm (64-bit).

If you are using a Raspberry Pi 5, note that the SPI1 routing and /boot/firmware/config.txt overlay syntax require slight modifications, which we will cover in the configuration steps. The Python code provided targets the Pi 4's SPI0 bus.

Hardware Spec Sheet & GPIO Pin Mapping

The Waveshare 3.5" SPI LCD (B) uses a shared SPI bus architecture. The ILI9486 display driver and the XPT2046 touch controller share the MOSI, MISO, and SCLK lines, but they require separate Chip Select (CS) and Data/Command (DC) pins to avoid bus collisions. Below is the exact pin mapping you need to wire the display to the Pi 4's 40-pin header.

Display Pin Pi GPIO (BCM) Pi Pin # Function Wire Color (Typical)
VCC N/A 1 (3.3V) Logic Power Red
GND N/A 6 Ground Black
MOSI GPIO 10 19 SPI0 MOSI (Shared) Green
MISO GPIO 9 21 SPI0 MISO (Shared) Yellow
SCLK GPIO 11 23 SPI0 Clock (Shared) Orange
CS (LCD) GPIO 8 24 SPI0 CE0 (Display) Blue
DC / RS GPIO 25 22 Data/Command Select Purple
RST GPIO 27 13 Reset (Active Low) Gray
BL GPIO 24 18 Backlight PWM White
T_CS GPIO 7 26 SPI0 CE1 (Touch) Brown
T_IRQ GPIO 17 11 Touch Interrupt Green/White
Callout Tip: SPI Speed Limits
The ILI9486 controller theoretically supports up to 64MHz SPI clock speeds, but on the Pi 4's breadboard jumper wires, signal integrity degrades past 32MHz, causing screen tearing or inverted colors. We will cap the SPI frequency at 32MHz in the kernel overlay configuration.

Step-by-Step Wiring and Kernel Configuration

Before writing any application code, the Raspberry Pi's kernel must be instructed to load the correct device tree overlays for both the framebuffer (display) and the input subsystem (touch).

  1. Physical Wiring: Connect the display to the Pi 4 GPIO header using the pin mapping table above. Ensure the Pi is completely powered off and disconnected from mains before seating the jumper wires.
  2. Enable SPI Interface: Boot the Pi and open the terminal. Run sudo raspi-config, navigate to Interface Options > SPI, and enable it.
  3. Edit the Firmware Config: In Raspberry Pi OS Bookworm and later, the configuration file moved from /boot/config.txt to /boot/firmware/config.txt. Open it with nano:
    sudo nano /boot/firmware/config.txt
  4. Add Display and Touch Overlays: Scroll to the bottom of the file and append the following lines. This loads the fbtft driver for the ILI9486 and the ads7846 driver (which is compatible with the XPT2046 touch chip).
# Enable SPI bus
dtparam=spi=on

# ILI9486 Display Overlay (32MHz speed, hardware reset on GPIO 27)
dtoverlay=waveshare35b,speed=32000000

# XPT2046 Touch Overlay (Interrupt on GPIO 17, Chip Select CE1)
dtoverlay=ads7846,penirq=17,penirq_pull=2,speed=50000,xohms=60
  1. Reboot and Verify Framebuffer: Save the file (Ctrl+O, Enter, Ctrl+X) and reboot. Once back in the terminal, verify the framebuffer spawned by running ls /dev/fb*. You should see /dev/fb0 (HDMI) and /dev/fb1 (SPI LCD).

Compilable Pygame Touch Interface Code

To build a responsive GUI, we use pygame for rendering and evdev to read raw touch events directly from the Linux input subsystem. This bypasses the X server, allowing for lightweight, kiosk-style embedded applications.

Prerequisites: Install the required libraries via sudo apt install python3-pygame python3-evdev.

import pygame
import evdev
import sys
import os
import select

# --- HARDWARE & PIN DEFINITIONS ---
# Target: Raspberry Pi 4 Model B + Waveshare 3.5" SPI LCD (B)
SCREEN_WIDTH = 480
SCREEN_HEIGHT = 320

# Touch Controller: XPT2046 mapped via ads7846 overlay
# The kernel assigns event0 to the first input device (HDMI/USB)
# and event1 or event2 to the SPI touch controller. 
TOUCH_DEVICE_PATH = '/dev/input/event1' 

# Calibration factors for XPT2046 raw to screen coordinates
# (Derived from ts_calibrate matrix for this specific panel)
CALIB_X_SCALE = 0.075
CALIB_Y_SCALE = 0.058
CALIB_X_OFFSET = -12
CALIB_Y_OFFSET = -25

def init_display():
    """Initialize Pygame with the SPI framebuffer."""
    os.environ["SDL_FBDEV"] = "/dev/fb1"
    os.environ["SDL_MOUSEDRV"] = "TSLIB"
    os.environ["SDL_MOUSEDEV"] = TOUCH_DEVICE_PATH
    
    pygame.init()
    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
    pygame.mouse.set_visible(False)
    return screen

def draw_ui(screen, touch_x, touch_y):
    """Render basic UI elements and touch coordinates."""
    screen.fill((30, 30, 30))
    font = pygame.font.SysFont('Arial', 24)
    
    # Draw a target circle at the touch location
    if touch_x > 0 and touch_y > 0:
        pygame.draw.circle(screen, (0, 200, 255), (int(touch_x), int(touch_y)), 20)
    
    # Draw text
    text = font.render(f"Touch X: {touch_x:.0f}  Y: {touch_y:.0f}", True, (255, 255, 255))
    screen.blit(text, (20, 20))
    
    quit_text = font.render("Tap Red Box to Exit", True, (255, 100, 100))
    screen.blit(quit_text, (20, 260))
    pygame.draw.rect(screen, (200, 0, 0), (250, 250, 200, 50))
    
    pygame.display.update()

def main():
    screen = init_display()
    
    try:
        device = evdev.InputDevice(TOUCH_DEVICE_PATH)
    except FileNotFoundError as e:
        print(f"CRITICAL ERROR: {e}")
        print("Touch device not found. Check kernel overlays and /dev/input/ nodes.")
        sys.exit(1)
    except PermissionError:
        print(f"Permission denied on {TOUCH_DEVICE_PATH}. Run with sudo or add user to 'input' group.")
        sys.exit(1)

    raw_x, raw_y = 0, 0
    
    while True:
        # Non-blocking event loop using select
        r, w, x = select.select([device], [], [], 0.01)
        if r:
            for event in device.read():
                if event.type == evdev.ecodes.EV_ABS:
                    if event.code == evdev.ecodes.ABS_X:
                        raw_x = event.value
                    elif event.code == evdev.ecodes.ABS_Y:
                        raw_y = event.value
                
                # Map raw ADC values to screen pixels
                screen_x = (raw_x * CALIB_X_SCALE) + CALIB_X_OFFSET
                screen_y = (raw_y * CALIB_Y_SCALE) + CALIB_Y_OFFSET
                
                # Clamp to screen boundaries
                screen_x = max(0, min(SCREEN_WIDTH, screen_x))
                screen_y = max(0, min(SCREEN_HEIGHT, screen_y))
                
                draw_ui(screen, screen_x, screen_y)
                
                # Exit condition: Tap the red box area
                if event.type == evdev.ecodes.EV_KEY and event.value == 1: # BTN_TOUCH press
                    if 250 <= screen_x <= 450 and 250 <= screen_y <= 300:
                        pygame.quit()
                        sys.exit(0)

if __name__ == '__main__':
    main()

Debugging: Fixing the "/dev/input/event0" Error

The most common failure point when integrating SPI touchscreens is the kernel failing to bind the touch controller to the input subsystem. If your script crashes immediately upon launch, you will likely see this exact error string:

FileNotFoundError: [Errno 2] No such file or directory: '/dev/input/event0'

This means the evdev library is looking for an input node that does not exist. Here are the ranked causes and how to fix them.

Ranked Causes and Fixes

  1. Missing or Misspelled Touch Overlay (Most Likely): The ads7846 overlay was not loaded. Open /boot/firmware/config.txt and ensure dtoverlay=ads7846,penirq=17... is present and not commented out. Run vcdbg log msg 2>&1 | grep dtb to check for overlay loading errors during boot.
  2. Wrong Event Node Assignment: The Pi assigns event nodes dynamically. If you have a USB keyboard or the HDMI audio driver loaded, they might claim event0. Run cat /proc/bus/input/devices to find the exact node assigned to the ADS7846 Touchscreen. Update the TOUCH_DEVICE_PATH variable in the Python script accordingly (e.g., to /dev/input/event1).
  3. Chip Select (CS) Pin Conflict: The XPT2046 requires SPI0 CE1 (GPIO 7 / Pin 26). If another device or overlay (like an RTC or secondary SPI flash) is holding GPIO 7, the touch controller will fail to initialize. Check for conflicting dtoverlay entries in your config.
The First 3 Things to Check When Touch Fails:
  1. Verify the Overlay: Run cat /boot/firmware/config.txt | grep ads7846 to confirm the kernel instruction is active.
  2. Check Physical Continuity: Use a multimeter in continuity mode to test the T_IRQ (GPIO 17) and T_CS (GPIO 7) jumper wires. A loose Dupont connector on the interrupt pin will result in a dead digitizer.
  3. Inspect evdev Permissions: Run ls -l /dev/input/. If the node exists but your user lacks read permissions, add your user to the input group: sudo usermod -a -G input pi and reboot.

How to Extend or Simplify Your Touch Build

Depending on your project's end goal, you may want to scale this architecture up for commercial enclosures or strip it down for rapid prototyping.

Simplifying the Build (The DSI Alternative)

If you are tired of managing SPI jumper wires, kernel overlays, and calibration matrices, switch to a DSI (Display Serial Interface) touchscreen, such as the Official Raspberry Pi 7" Touch Display. DSI uses a dedicated 15-pin ribbon cable that carries both high-speed video and I2C touch data. It requires zero GPIO wiring, no config.txt overlays, and touch input works natively out-of-the-box as a standard HID mouse device. The trade-off is a higher price point (~$60 USD vs ~$25 USD for SPI) and a larger physical footprint.

Extending the Build (IoT & Smart Home Integration)

To turn this Pygame interface into a functional smart home dashboard, extend the main while loop with the paho-mqtt library. When a user taps a specific UI coordinate (e.g., a virtual light switch), publish a payload to your MQTT broker:

import paho.mqtt.client as mqtt

client = mqtt.Client("PiTouchDash")
client.connect("192.168.1.100", 1883, 60)

# Inside your touch event loop:
if button_tapped == "living_room_light":
    client.publish("home/livingroom/light", "TOGGLE")

For production deployments, consider replacing pygame with LVGL (Light and Versatile Graphics Library) via its MicroPython or C bindings. LVGL offers hardware-accelerated rendering, anti-aliased fonts, and complex widgets (sliders, gauges) that pygame struggles to draw efficiently on a 32MHz SPI bus.