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 |
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).
- 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.
- Enable SPI Interface: Boot the Pi and open the terminal. Run
sudo raspi-config, navigate to Interface Options > SPI, and enable it. - Edit the Firmware Config: In Raspberry Pi OS Bookworm and later, the configuration file moved from
/boot/config.txtto/boot/firmware/config.txt. Open it with nano:sudo nano /boot/firmware/config.txt - Add Display and Touch Overlays: Scroll to the bottom of the file and append the following lines. This loads the
fbtftdriver for the ILI9486 and theads7846driver (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
- Reboot and Verify Framebuffer: Save the file (
Ctrl+O,Enter,Ctrl+X) and reboot. Once back in the terminal, verify the framebuffer spawned by runningls /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
- Missing or Misspelled Touch Overlay (Most Likely): The
ads7846overlay was not loaded. Open/boot/firmware/config.txtand ensuredtoverlay=ads7846,penirq=17...is present and not commented out. Runvcdbg log msg 2>&1 | grep dtbto check for overlay loading errors during boot. - 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. Runcat /proc/bus/input/devicesto find the exact node assigned to theADS7846 Touchscreen. Update theTOUCH_DEVICE_PATHvariable in the Python script accordingly (e.g., to/dev/input/event1). - 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
dtoverlayentries in your config.
- Verify the Overlay: Run
cat /boot/firmware/config.txt | grep ads7846to confirm the kernel instruction is active. - 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.
- 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 piand 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.






