Building a reliable raspberry pi touchscreen gui for industrial control, home automation, or kiosk displays requires moving past basic terminal scripts and into hardware-accelerated windowing. With the release of Raspberry Pi OS Bookworm and the Raspberry Pi 5, the default display server shifted from X11 to Wayland, breaking many legacy GUI tutorials. This guide cuts through the outdated advice, providing a decision-forward framework, exact hardware specs, and a complete, compilable PyQt5 application with hardware GPIO integration.
The Verdict: Which GUI Framework Wins?
When selecting a framework for a Raspberry Pi touchscreen GUI, your choice dictates your threading model, rendering performance, and OS compatibility. Here is the decision path to select the right tool for your build:
| Requirement / Constraint | Recommended Framework | Why? |
|---|---|---|
| Need native Wayland support on Pi OS Bookworm out-of-the-box? | CustomTkinter | Tkinter is built-in and plays nicely with Wayland, but lacks complex multi-threading. |
| Need smartphone-like swipe gestures and fluid animations? | Kivy | OpenGL ES accelerated, but heavy on RAM and uses non-standard widget paradigms. |
| Need industrial widgets, robust multi-threading, and precise layout control? | PyQt5 (Default Pick) | Industry standard for HMIs. Requires switching Pi OS back to X11, but offers unmatched stability and widget depth. |
Parts List & Hardware Spec Sheet
Avoid the official 7-inch DSI display for new Pi 5 builds; the DSI ribbon cable is fragile, and driver support on third-party OS images is inconsistent. Instead, use an HDMI/USB combo screen for plug-and-play reliability.
| Component | Exact Variant | Estimated Price | Notes |
|---|---|---|---|
| Compute Board | Raspberry Pi 5 (4GB RAM) | $60.00 | 4GB is the minimum for smooth PyQt5 rendering without swapping. |
| Display | Waveshare 5" HDMI LCD (800x480) | $45.00 | Capacitive touch via USB, video via Micro-HDMI. See Waveshare Wiki. |
| Power Supply | Official 27W USB-C PD PSU | $12.00 | Required to prevent brownouts when driving HDMI and USB peripherals. |
| Cooling | Raspberry Pi Active Cooler | $5.00 | Mandatory for Pi 5 under GUI load. |
| Cables | Micro-HDMI to HDMI + USB-A to C | $10.00 | Use the Micro-HDMI port closest to the USB-C power input (Port 0). |
Wiring & Pin Mapping
This build integrates the GUI with physical hardware using gpiozero, the modern standard for Pi GPIO control. We will map two GPIO pins to control a status relay and a fault indicator LED.
| Pi 5 Interface | Destination | Function |
|---|---|---|
| Micro-HDMI Port 0 | Waveshare Display HDMI | Video signal (800x480 @ 60Hz) |
| USB 2.0 Port (Black) | Waveshare Touch USB-C | Capacitive touch digitizer data |
| GPIO 17 (Pin 11) | Relay Module IN1 | Main load control (e.g., pump or motor) |
| GPIO 27 (Pin 13) | Red LED (+ via 330Ω) | Fault / Error visual indicator |
| GND (Pin 9) | Relay GND / LED (-) | Common ground reference |
Building the GUI: Complete PyQt5 Code
The following code targets the Raspberry Pi 5 (4GB) running Pi OS Bookworm. It uses PyQt5 for the interface and gpiozero for hardware control. gpiozero is preferred over RPi.GPIO because it handles pin cleanup automatically on exit and supports the Pi 5's new RP1 southbridge chip natively. For deeper API details, refer to the gpiozero documentation.
Prerequisites: Run sudo apt update && sudo apt install python3-pyqt5 python3-gpiozero
import sys
import os
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QLabel, QMessageBox
from PyQt5.QtCore import Qt
from gpiozero import LED, OutputDevice
# --- PIN DEFINITIONS ---
# Using gpiozero for Pi 5 RP1 chip compatibility
RELAY_PIN = 17
FAULT_LED_PIN = 27
class TouchHMI(QWidget):
def __init__(self):
super().__init__()
self.init_hardware()
self.init_ui()
def init_hardware(self):
try:
# Initialize GPIO devices
self.relay = OutputDevice(RELAY_PIN, active_high=True, initial_value=False)
self.fault_led = LED(FAULT_LED_PIN)
self.fault_led.off()
except Exception as e:
print(f'Hardware Init Error: {e}')
self.relay = None
self.fault_led = None
def init_ui(self):
self.setWindowTitle('Flux Control HMI')
self.setFixedSize(800, 480) # Match Waveshare native resolution
self.setStyleSheet('background-color: #1e1e1e;')
layout = QVBoxLayout()
# Status Label
self.status_label = QLabel('SYSTEM STATUS: IDLE', self)
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet('color: #00ff00; font-size: 32px; font-weight: bold;')
layout.addWidget(self.status_label)
# Main Toggle Button
self.toggle_btn = QPushButton('ENGAGE MAIN DRIVE', self)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setStyleSheet('''
QPushButton {
background-color: #333333; color: white; font-size: 28px;
border: 2px solid #555555; border-radius: 10px; padding: 40px;
}
QPushButton:checked {
background-color: #2e7d32; border: 2px solid #4caf50;
}
''')
self.toggle_btn.clicked.connect(self.toggle_drive)
layout.addWidget(self.toggle_btn)
self.setLayout(layout)
def toggle_drive(self, checked):
if self.relay is None:
self.trigger_fault('GPIO Relay not initialized!')
return
if checked:
self.relay.on()
self.status_label.setText('SYSTEM STATUS: RUNNING')
self.status_label.setStyleSheet('color: #00ff00; font-size: 32px; font-weight: bold;')
self.toggle_btn.setText('DISENGAGE MAIN DRIVE')
else:
self.relay.off()
self.status_label.setText('SYSTEM STATUS: IDLE')
self.status_label.setStyleSheet('color: #ffaa00; font-size: 32px; font-weight: bold;')
self.toggle_btn.setText('ENGAGE MAIN DRIVE')
def trigger_fault(self, message):
if self.fault_led:
self.fault_led.blink(on_time=0.5, off_time=0.5)
self.status_label.setText(f'FAULT: {message}')
self.status_label.setStyleSheet('color: #ff0000; font-size: 28px; font-weight: bold;')
QMessageBox.critical(self, 'Hardware Fault', message)
if __name__ == '__main__':
# Force XCB platform if running in mixed environment
os.environ['QT_QPA_PLATFORM'] = 'xcb'
app = QApplication(sys.argv)
hmi = TouchHMI()
hmi.show()
try:
sys.exit(app.exec_())
except SystemExit:
print('HMI Shutting down gracefully...')
# gpiozero handles pin cleanup automatically on exit
Debugging: "Could not load the Qt platform plugin xcb"
When migrating to Pi OS Bookworm, the most common failure when launching PyQt5 apps is an immediate crash with the following exact error string:
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found.
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
This happens because Bookworm defaults to Wayland, but PyQt5 relies on the XCB (X11 C Binding) plugin. Here are the first three things to check and fix, ranked by likelihood:
- Wayland is Active (Most Likely): Open a terminal and run
echo $XDG_SESSION_TYPE. If it returnswayland, you must switch to X11. Runsudo raspi-config, navigate to Advanced Options -> Wayland, select X11, reboot, and try again. See the official Pi OS display documentation for backend details. - Missing XCB Dependencies: If you are already on X11 but still get the error, the underlying C libraries are missing. Fix this by running:
sudo apt install libxcb-xinerama0 libxcb-cursor0. - SSH Headless Execution: If you are running the script via SSH without X11 forwarding, Qt has no display to attach to. You must either run it locally on the Pi, or SSH in using
ssh -X pi@ip-addressand ensureX11Forwarding yesis set in the Pi's/etc/ssh/sshd_config.
unclutter (sudo apt install unclutter) and add @unclutter -idle 1 to your ~/.config/lxsession/LXDE-pi/autostart file.
Extending and Simplifying Your Build
Once your baseline raspberry pi touchscreen gui is stable, you will inevitably need to scale the project up or strip it down for cost optimization.
How to Simplify (Cost & Power Reduction)
If your application only requires 3 or 4 static buttons and a temperature readout, drop the HDMI display entirely. Switch to a 3.5" SPI TFT (ILI9341) driven by the Pillow library and direct framebuffer writes. This eliminates the HDMI/USB power draw, allows you to step down to a Raspberry Pi Zero 2 W ($15), and boots directly into a headless Python script that paints the framebuffer, saving roughly 400mA of current draw and $50 in BOM costs.
How to Extend (Network & Telemetry)
To transform this local HMI into a networked SCADA node, integrate the paho-mqtt Python library.
- Add a Background Thread: Use
QThreadin PyQt5 to run the MQTT client loop. Never run blocking network loops on the main GUI thread, or the touchscreen will freeze and drop touch events. - Publish State: When the
toggle_drivemethod fires, publish the boolean state to an MQTT topic likehmi/pi5/main_drive/state. - Subscribe to Telemetry: Subscribe to sensor topics and use Qt's Signal/Slot mechanism to safely update the
QLabelwidgets from the background thread without causing a segmentation fault.
By anchoring your build on PyQt5 with an X11 backend and using gpiozero for hardware abstraction, you ensure your Raspberry Pi touchscreen GUI remains responsive, debuggable, and ready for industrial deployment.






