Building a local, offline touchscreen dashboard for a Raspberry Pi used to mean wrestling with X11 display servers and laggy web browsers. With the shift to Wayland on Raspberry Pi OS Bookworm and the release of the Raspberry Pi 5, the hardware and software stack have fundamentally changed. If you want a snappy, native-feeling graphical user interface Raspberry Pi project without the overhead of a full web stack, you need the right framework and the right hardware pairing.

This guide walks through building a live environmental dashboard targeting the Raspberry Pi 5 (4GB variant) running Raspberry Pi OS (Bookworm, 64-bit, Wayland). We will use PyQt6 for the GUI and read live data from a BME280 I2C sensor.

The Verdict: Which GUI Framework to Choose

Before writing a single line of code, you must choose your GUI framework. The decision path below terminates in a single concrete recommendation for local touch dashboards.

Framework Touch Support Resource Usage Best For Verdict
Tkinter / CustomTkinter Poor (Requires manual event binding) Very Low Simple data entry, basic buttons Skip for modern touch
Kivy Excellent (Native gestures) High (Requires OpenGL ES) Custom multi-touch gestures Overkill for static dashboards
Web (Flask/React) Good (Via Chromium kiosk) Very High (Browser RAM overhead) Remote access, network-dependent UIs Pick only if network-dependent
PyQt6 Native / Excellent Medium Local offline dashboards, industrial HMI DEFAULT PICK
Decision Path Summary: If your dashboard must be accessed remotely via a browser, choose a Web stack. If you need complex multi-touch pinch-to-zoom, choose Kivy. For 90% of local, single-touch hardware dashboards, choose PyQt6. It integrates flawlessly with Wayland, handles touch events as standard mouse clicks, and provides hardware-accelerated rendering without the 300MB RAM tax of a Chromium kiosk.

Hardware Spec Sheet & Parts List

This build relies on the DSI (Display Serial Interface) port rather than HDMI. DSI bypasses the GPU's HDMI encoding overhead, resulting in lower latency and freeing up your micro-HDMI ports for external monitors.

Component Exact Variant Estimated Price (2026) Notes
Microcontroller Raspberry Pi 5 (4GB) $60.00 4GB is the sweet spot; 8GB is wasted on local PyQt6.
Display Waveshare 7.9" DSI Touchscreen (800x1280) $45.00 Must be the DSI version, not HDMI.
Sensor Bosch BME280 Breakout (3.3V I2C) $12.00 Ensure it has onboard pull-up resistors.
Power Supply Official Raspberry Pi 27W USB-C PD $12.00 Required to prevent brownouts with the DSI screen.
Cooling Raspberry Pi Active Cooler $5.00 Mandatory for Pi 5 under GUI loads.
Storage 32GB SanDisk Extreme A2 microSD $14.00 A2 rating ensures fast OS swap operations.

Physical Assembly & I2C Pin Mapping

The Raspberry Pi 5 features two DSI ports. The physical connection is straightforward, but the I2C sensor wiring requires attention to the Pi 5's specific GPIO layout.

DSI Ribbon Cable Routing

  1. Locate DSI Port 1 on the Pi 5 (the connector closest to the USB-C power input).
  2. Gently lift the black plastic retaining clip on the DSI port.
  3. Insert the Waveshare ribbon cable with the metal contact pins facing inward (towards the PCB, not the outer plastic shell).
  4. Press the retaining clip down firmly until it clicks.

BME280 I2C Pin Mapping

We are using the primary I2C bus (Bus 1). Do not use the 5V pin; the BME280 is strictly a 3.3V logic device. Feeding it 5V will permanently destroy the sensor's internal barometer.

BME280 Breakout Pin Raspberry Pi 5 GPIO Physical Pin # Function
VIN / VCC 3V3 1 3.3V Power
GND GND 6 Ground Reference
SCL GPIO 3 5 I2C Clock
SDA GPIO 2 3 I2C Data

PyQt6 Touchscreen Dashboard Code

This code targets the Raspberry Pi 5 (4GB) running Python 3.11+. It initializes a full-screen PyQt6 window, polls the BME280 sensor every 2 seconds, and updates the UI. It includes robust error handling for I2C bus failures, which are the most common point of failure in embedded GUIs.

Prerequisites: Run sudo apt install python3-pyqt6 python3-smbus2 and pip3 install RPi.bme280 before executing.

import sys
import time
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer, Qt
from PyQt6.QtGui import QFont

# Hardware Pin & Bus Definitions
I2C_BUS_ID = 1
BME280_I2C_ADDRESS = 0x76  # Default for most Adafruit/Waveshare breakouts

def initialize_sensor():
    """Attempts to connect to the BME280 via I2C."""
    try:
        import smbus2
        import bme280
        bus = smbus2.SMBus(I2C_BUS_ID)
        calibration_params = bme280.load_calibration_params(bus, BME280_I2C_ADDRESS)
        return bus, calibration_params, bme280
    except Exception as e:
        print(f"[FATAL] Sensor initialization failed: {e}")
        return None, None, None

class DashboardUI(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Enviro Dashboard")
        self.showFullScreen() # Optimized for touch kiosk mode
        
        self.bus, self.calibration, self.bme280_lib = initialize_sensor()
        
        # UI Layout
        layout = QVBoxLayout()
        self.title_label = QLabel("Environmental Monitor")
        self.title_label.setFont(QFont("Arial", 24, QFont.Weight.Bold))
        self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        
        self.data_label = QLabel("Awaiting sensor data...")
        self.data_label.setFont(QFont("Arial", 36))
        self.data_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        
        layout.addWidget(self.title_label)
        layout.addWidget(self.data_label)
        self.setLayout(layout)
        
        # Timer for non-blocking UI updates (2000ms interval)
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.update_sensor_data)
        self.timer.start(2000)

    def update_sensor_data(self):
        """Polls the I2C bus and updates the GUI."""
        if not self.bus:
            self.data_label.setText("SENSOR OFFLINE")
            self.data_label.setStyleSheet("color: red;")
            return

        try:
            data = self.bme280_lib.sample(self.bus, BME280_I2C_ADDRESS, self.calibration)
            temp_c = data.temperature
            humidity = data.humidity
            pressure_hpa = data.pressure
            
            display_text = (
                f"Temp: {temp_c:.1f} °C\n"
                f"Humidity: {humidity:.1f} %\n"
                f"Pressure: {pressure_hpa:.0f} hPa"
            )
            self.data_label.setText(display_text)
            self.data_label.setStyleSheet("color: #2e7d32;")
            
        except OSError as e:
            # Catches I2C bus dropouts without crashing the GUI
            print(f"[WARN] I2C Read Error: {e}")
            self.data_label.setText("I2C BUS ERROR")
            self.data_label.setStyleSheet("color: orange;")

if __name__ == "__main__":
    app = QApplication(sys.argv)
    dashboard = DashboardUI()
    dashboard.show()
    sys.exit(app.exec())

Debugging: First 3 Things to Check When It Fails

Embedded GUIs fail at the intersection of display servers and hardware buses. When your dashboard refuses to launch or displays stale data, follow this ranked troubleshooting path.

1. Exact Error: qt.qpa.plugin: Could not load the Qt platform plugin "wayland"

  • Cause: Raspberry Pi OS Bookworm defaults to the Wayland display server, but the base PyQt6 installation often lacks the Wayland-specific platform plugin, attempting to fall back to XCB (X11) and failing.
  • Fix: Install the missing Wayland bridge. Run sudo apt install qt6-wayland. If it still fails, force the X11 fallback by launching your script with: QT_QPA_PLATFORM=xcb python3 dashboard.py.

2. Exact Error: OSError: [Errno 121] Remote I/O error

  • Cause: The Raspberry Pi cannot acknowledge the BME280 on the I2C bus. This is almost always a physical layer issue: missing pull-up resistors, a loose jumper wire, or an incorrect I2C address.
  • Fix: Run i2cdetect -y 1 in the terminal. If the grid is empty, check your wiring. If you see 77 instead of 76, change the BME280_I2C_ADDRESS variable in the code to 0x77.

3. Symptom: GUI Launches but Touch Input is Ignored or Offset

  • Cause: Wayland is misidentifying the DSI touch controller as a generic mouse, or the display scaling factor is miscalculating touch coordinates.
  • Fix: Ensure you are using the official Waveshare DSI overlay. If touch is offset, open /boot/firmware/config.txt and ensure dtoverlay=vc4-kms-v3d is active, and add ignore_lcd=0 to force the firmware to probe the DSI panel natively.

Extending and Simplifying the Build

Once the baseline dashboard is stable, you will likely want to adapt it to your specific project constraints.

How to Extend (Adding Network & Relays)

To turn this monitor into a controller, add an MQTT client. Because PyQt6 relies on an event loop, do not use blocking network calls. Instead, use the paho-mqtt library and bind its callbacks to PyQt's pyqtSignal to safely update the UI from a background thread. To control physical hardware, wire an I2C relay module (like the PCF8574) to the same I2C bus, and add QPushButton widgets to your layout that trigger smbus2.write_byte_data() commands.

How to Simplify (Downgrading to Pi Zero 2 W)

If you want to port this exact build to a Raspberry Pi Zero 2 W to save costs ($15 vs $60), PyQt6 will consume too much RAM (leaving you vulnerable to OOM kills). The Simplification Path: 1. Swap PyQt6 for CustomTkinter (install via pip3 install customtkinter). 2. Change the display to a 3.5" SPI TFT screen (ILI9486 driver). 3. Reduce the UI update timer from 2000ms to 5000ms to give the single-core-bound SPI bus time to clear the framebuffer. 4. Remove the showFullScreen() call and replace it with fixed geometry matching the SPI screen's native 480x320 resolution.

Author Note on Production Deployments: If this dashboard is going into a 24/7 kiosk environment, wrap the Python execution in a systemd service with Restart=always and RestartSec=5. I2C buses on the Pi can occasionally lock up due to EMI from nearby switching power supplies; an automatic service restart is the only reliable way to guarantee 99.9% uptime without manual intervention.