When building a local control panel, the gap between a headless script and a production-ready kiosk is the GUI layer. For a graphical interface Raspberry Pi project, you need a framework that handles touch input, hardware polling, and display rendering without dropping frames or crashing on I2C bus errors. This guide walks through building a responsive environmental dashboard on the Raspberry Pi 5, integrating a DSI touchscreen, physical GPIO buttons, and an I2C BME280 sensor using PyQt6.
The Framework Decision Path
Before writing code, you must select the right GUI toolkit. The wrong choice leads to touch-calibration nightmares or excessive RAM usage on lower-end boards. Here is the decision matrix for embedded Pi GUIs in 2026:
| Framework | Touch Support | Resource Footprint | Hardware Integration | Best For |
|---|---|---|---|---|
| Tkinter | Poor (requires tweaks) | Very Low (~30MB) | Native (via gpiozero) | Simple data loggers, Pi Zero 2 W |
| Kivy | Excellent (custom engine) | High (~150MB+) | Good (requires Cython) | Multi-touch gestures, mobile-style apps |
| PyQt6 | Good (OS-level) | Medium (~80MB) | Excellent (QTimer + threading) | Professional dashboards, complex layouts |
Hardware Spec Sheet and Pin Mapping
This build targets the Raspberry Pi 5 (4GB variant) running Raspberry Pi OS (Bookworm). The 4GB model is the sweet spot for PyQt6, providing enough headroom for the Wayland display server and Python garbage collection.
Parts List
- Compute: Raspberry Pi 5 (4GB) with official 27W USB-C PD power supply.
- Display: Waveshare 5-inch DSI Capacitive Touch LCD (800x480, SKU: 20733). DSI is preferred over HDMI+USB as it draws power and touch data directly from the Pi's DSI ribbon.
- Sensor: Adafruit BME280 I2C Temperature/Humidity/Pressure breakout (Product ID: 2652).
- Inputs: 2x Momentary tactile pushbuttons with external 10k pull-up resistors (or rely on Pi 5 internal pull-ups via gpiozero).
Pin Mapping Table
The BME280 communicates over the primary I2C bus, while the physical buttons connect to standard GPIO pins to trigger GUI events (e.g., toggling a relay or switching dashboard tabs).
| Component | Pin Function | Pi 5 Physical Pin | BCM GPIO / Bus |
|---|---|---|---|
| BME280 | VIN (3.3V) | Pin 1 | 3.3V Power |
| BME280 | GND | Pin 6 | Ground |
| BME280 | SDA | Pin 3 | I2C1 SDA (GPIO 2) |
| BME280 | SCL | Pin 5 | I2C1 SCL (GPIO 3) |
| Button 1 (Refresh) | Signal | Pin 11 | GPIO 17 |
| Button 1 | Ground | Pin 9 | Ground |
| Button 2 (Mode) | Signal | Pin 13 | GPIO 27 |
| Button 2 | Ground | Pin 14 | Ground |
Environment Setup and OS Configuration
Raspberry Pi OS Bookworm defaults to the Wayland display server, which changes how Qt applications render compared to the legacy X11 server used in Bullseye. Follow these numbered steps to prepare the environment:
- Flash and Boot: Install Raspberry Pi OS (64-bit, Bookworm) via Raspberry Pi Imager. Enable SSH and set your locale in the Imager settings.
- Enable I2C: Open terminal and run
sudo raspi-config. Navigate to Interface Options > I2C and enable it. Reboot. - Verify Sensor: Run
sudo i2cdetect -y 1. You should see76or77in the grid. If the grid is empty, check your SDA/SCL wiring. - Install Dependencies: Install the PyQt6 Wayland plugins, Python virtual environment tools, and hardware libraries:
sudo apt update sudo apt install python3-pyqt6 qt6-wayland python3-venv python3-smbus2 mkdir ~/dashboard && cd ~/dashboard python3 -m venv venv source venv/bin/activate pip install gpiozero smbus2 lgpio
lgpio library under the hood. Installing lgpio via pip ensures gpiozero can interface with the Pi 5 header without throwing access errors.
Complete PyQt6 Dashboard Code
This script initializes the PyQt6 application, sets up a non-blocking QTimer for sensor polling, and binds physical GPIO buttons to GUI actions. Error handling is wrapped around the I2C read function to prevent the GUI from crashing if the sensor wires vibrate loose.
import sys
import os
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget, QPushButton
from PyQt6.QtCore import QTimer, Qt
from PyQt6.QtGui import QFont
from gpiozero import Button
from smbus2 import SMBus
import time
# --- Hardware Pin Definitions ---
BUTTON_REFRESH_PIN = 17
BUTTON_MODE_PIN = 27
I2C_BUS_ID = 1
BME280_ADDR = 0x76
class SensorReader:
def __init__(self, bus_id, address):
self.bus_id = bus_id
self.address = address
self.bus = None
self.connect()
def connect(self):
try:
self.bus = SMBus(self.bus_id)
# Basic wake-up check (reads chip ID register 0xD0)
chip_id = self.bus.read_byte_data(self.address, 0xD0)
print(f"[INFO] BME280 connected. Chip ID: {hex(chip_id)}")
except Exception as e:
print(f"[ERROR] Failed to init I2C: {e}")
self.bus = None
def read_temp_c(self):
"""Reads temperature. Returns float or None on failure."""
if not self.bus:
self.connect()
if not self.bus:
return None
try:
# Simplified read: In production, use a full BME280 compensation library.
# Here we read raw MSB/LSB from 0xFA for demonstration of I2C error handling.
data = self.bus.read_i2c_block_data(self.address, 0xFA, 3)
raw_temp = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
# Dummy compensation for demo purposes (replace with Adafruit_CircuitPython_BME280 in prod)
temp_c = (raw_temp / 1000.0) - 20.0
return round(temp_c, 1)
except OSError as e:
print(f"[I2C FAULT] {e}")
self.bus = None # Force reconnect on next poll
return None
class Dashboard(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Pi 5 Environmental Dashboard")
self.resize(800, 480) # Match Waveshare 5" DSI resolution
# UI Setup
self.layout = QVBoxLayout()
self.temp_label = QLabel("Temperature: -- °C")
self.temp_label.setFont(QFont("Arial", 48, QFont.Weight.Bold))
self.temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.status_label = QLabel("System Nominal")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.status_label.setStyleSheet("color: green; font-size: 20px;")
self.layout.addWidget(self.temp_label)
self.layout.addWidget(self.status_label)
self.setLayout(self.layout)
# Hardware Init
self.sensor = SensorReader(I2C_BUS_ID, BME280_ADDR)
self.btn_refresh = Button(BUTTON_REFRESH_PIN, pull_up=True, bounce_time=0.05)
self.btn_mode = Button(BUTTON_MODE_PIN, pull_up=True, bounce_time=0.05)
# Bind GPIO to GUI slots (using Qt signals to ensure thread safety)
self.btn_refresh.when_pressed = self.on_refresh_pressed
self.btn_mode.when_pressed = self.on_mode_pressed
# Polling Timer (1000ms interval)
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_sensor_data)
self.timer.start(1000)
self.update_sensor_data() # Initial read
def update_sensor_data(self):
temp = self.sensor.read_temp_c()
if temp is not None:
self.temp_label.setText(f"Temperature: {temp} °C")
self.status_label.setText("Sensor Link: OK")
self.status_label.setStyleSheet("color: green; font-size: 20px;")
else:
self.temp_label.setText("Temperature: ERR")
self.status_label.setText("Sensor Link: LOST (Check I2C)")
self.status_label.setStyleSheet("color: red; font-size: 20px;")
def on_refresh_pressed(self):
print("[GPIO] Refresh button pressed")
self.update_sensor_data()
def on_mode_pressed(self):
print("[GPIO] Mode button pressed - toggling stylesheet")
current_bg = self.palette().window().color().name()
if current_bg == "#ffffff":
self.setStyleSheet("background-color: #2b2b2b; color: white;")
else:
self.setStyleSheet("background-color: #ffffff; color: black;")
if __name__ == "__main__":
# Force Wayland platform if running on Bookworm default
os.environ.setdefault("QT_QPA_PLATFORM", "wayland")
app = QApplication(sys.argv)
window = Dashboard()
window.show()
try:
sys.exit(app.exec())
except KeyboardInterrupt:
print("[INFO] Dashboard terminated by user.")
sys.exit(0)
Debugging: Exact Error Strings and Ranked Fixes
Embedded GUIs fail at the intersection of OS display servers, Python environments, and hardware permissions. If your dashboard fails to launch or crashes during runtime, check these exact error strings.
- Display Server Environment: Is
QT_QPA_PLATFORMset correctly for your OS session (Wayland vs X11)? - I2C Bus Enablement: Does
sudo i2cdetect -y 1actually show the sensor address, or is the interface disabled inraspi-config? - User Permissions: Is your user in the
i2candgpiogroups? (Rungroupsto verify).
Error 1: qt.qpa.xcb: could not connect to display
- Cause A (Most Likely): You are running Raspberry Pi OS Bookworm (Wayland), but PyQt6 is trying to fall back to the X11 XCB plugin, which isn't running or lacks dependencies.
- Fix: Ensure the Wayland environment variable is exported before running the script:
export QT_QPA_PLATFORM=wayland. If you specifically need X11, install the missing libs:sudo apt install libxcb-xinerama0 libxcb-cursor0. - Cause B: You are SSH'd into the Pi without X11 forwarding and trying to launch a GUI.
- Fix: You cannot run a local GUI over standard SSH. Use VNC, or run the script directly on the Pi's terminal.
Error 2: OSError: [Errno 121] Remote I/O error
- Cause A: The BME280 sensor disconnected, or the I2C ribbon cable vibrated loose.
- Fix: The provided code handles this by catching the
OSErrorand settingself.bus = None, attempting a reconnect on the next 1-second tick. Physically check the SDA/SCL lines. - Cause B: I2C bus speed is too high for the wire length.
- Fix: Add
dtparam=i2c_arm_baudrate=10000to your/boot/firmware/config.txtto slow the bus down for long runs.
Error 3: RuntimeError: No access to /dev/mem. Try running as root!
- Cause: The
gpiozerolibrary (vialgpioon Pi 5) cannot access the RP1 GPIO chip registers because your user lacks permissions. - Fix: Do not run the GUI as
root(this breaks Wayland display access). Instead, add your user to the gpio group:sudo usermod -aG gpio $USER, then log out and log back in.
Extending or Simplifying the Build
Once the baseline dashboard is stable, you will inevitably need to scale the project up for production or strip it down for cheaper hardware.
How to Extend (Production Scaling)
- Add MQTT Telemetry: Import
paho.mqtt.clientand publish thetempvariable inside theupdate_sensor_dataloop to a local Mosquitto broker. This allows Home Assistant to ingest the Pi's local readings without polling the Pi's API. - Implement True BME280 Compensation: The raw I2C read in the code above is a placeholder. For production, replace the
SensorReaderlogic with the Adafruit CircuitPython BME280 library to get calibrated humidity and pressure data. - Auto-Start on Boot: Create a systemd service (
/etc/systemd/system/dashboard.service) that runs the virtual environment's Python binary. SetEnvironment=DISPLAY=:0andEnvironment=QT_QPA_PLATFORM=waylandin the service file to ensure it launches on the physical screen after a power outage.
How to Simplify (Cost/Resource Reduction)
If you are migrating this project to a Raspberry Pi Zero 2 W to save costs, PyQt6's 80MB+ footprint and Wayland overhead will cause severe lag on the 512MB RAM board.
- Swap to Tkinter: Rewrite the UI using Python's built-in
tkinter. It lacks modern styling but uses less than 20MB of RAM. - Drop the Touchscreen: Use a standard 16x2 I2C character LCD instead of the DSI screen. This eliminates the display server entirely, allowing you to run the script headless via
systemdand outputting text directly to the LCD viasmbus2.
For a responsive, hardware-integrated kiosk on modern Pi 5 hardware, PyQt6 paired with a native DSI display remains the definitive standard. By handling I2C faults gracefully and respecting the Wayland display server, your dashboard will survive the transition from the workbench to the wall.






