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 |
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
- Locate DSI Port 1 on the Pi 5 (the connector closest to the USB-C power input).
- Gently lift the black plastic retaining clip on the DSI port.
- Insert the Waveshare ribbon cable with the metal contact pins facing inward (towards the PCB, not the outer plastic shell).
- 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 1in the terminal. If the grid is empty, check your wiring. If you see77instead of76, change theBME280_I2C_ADDRESSvariable in the code to0x77.
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.txtand ensuredtoverlay=vc4-kms-v3dis active, and addignore_lcd=0to 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.
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.






