Raspberry Pi

Raspberry Pi MQTT Display: Build a Touchscreen Control Panel in Python

Build a full-screen touchscreen control panel on a Raspberry Pi. Buttons publish MQTT commands, the screen reflects the real device state from retained messages, and the panel starts automatically on boot.

Raspberry Pi touch display showing buttons and device states as a raspberry pi mqtt display control panel
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

A raspberry pi mqtt display is a touchscreen panel that both shows what your devices are doing and lets you control them with a tap. In this tutorial you will build one in Python with Tkinter and paho-mqtt: buttons publish commands, labels update from the devices' own state topics, and the panel launches full screen when the Pi boots. The pattern works with relays, lights, pumps or anything else that speaks MQTT.

Command Topics vs State Topics

The most common mistake in DIY control panels is updating the button colour the moment it is pressed. That shows what you asked for, not what actually happened. If the relay board is offline, the panel lies.

The robust pattern separates two topics per device:

Topic Who publishes Retained Purpose
.../<device>/set The panel (or any controller) No A request: ON, OFF, TOGGLE
.../<device>/state The device itself Yes The truth: ON or OFF
.../<device>/status The device, plus last will Yes online or offline

The panel publishes to set and only changes its display when a message arrives on state. Because state is retained, the panel learns every device's current state the moment it subscribes, even right after a reboot. Commands are deliberately not retained, otherwise a device that reconnects would replay an old command. The retained messages guide explains why this split matters.

If you do not have devices yet, the Raspberry Pi relay over MQTT tutorial builds the device side that this panel controls.

Hardware

  • Raspberry Pi 4 or 5, or a Pi 3B+ for a simple panel.
  • The official Raspberry Pi Touch Display (7-inch or Touch Display 2) or any HDMI screen with USB touch.
  • Raspberry Pi OS with desktop (Bookworm). Tkinter runs on the Wayland desktop through Xwayland without extra configuration.

Install the Dependencies

sudo apt update
sudo apt install -y python3-tk python3-venv
python3 -m venv --system-site-packages ~/panel-env
~/panel-env/bin/pip install "paho-mqtt>=2.0"

The --system-site-packages flag lets the virtual environment use the system Tkinter package, which is not installable from pip.

The Threading Rule That Keeps Tkinter Stable

paho-mqtt's loop_start() runs network handling in a background thread, and its callbacks fire on that thread. Tkinter is not thread-safe: touching widgets from another thread causes random freezes and crashes, often days later. The fix is a queue. MQTT callbacks only push messages into a queue.Queue, and the Tkinter main loop drains the queue every 100 ms with root.after(). All widget updates happen on the GUI thread.

The Raspberry Pi MQTT Display Code

Save this as ~/panel.py:

#!/usr/bin/env python3
"""Touchscreen MQTT control panel for Raspberry Pi."""
import json
import os
import queue
import ssl
import tkinter as tk

import paho.mqtt.client as mqtt

HOST = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")          # YOUR_USERNAME
PASSWORD = os.getenv("MQTT_PASSWORD")          # YOUR_PASSWORD
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", "panel-e40b77")  # YOUR_CLIENT_ID
PREFIX = os.getenv("MQTT_PREFIX", "connectmq/demo/e40b77/home")

DEVICES = [
    ("hall_light", "Hall light"),
    ("garden_pump", "Garden pump"),
    ("garage_door", "Garage door"),
    ("heater", "Heater"),
]
SENSOR_TOPIC = f"{PREFIX}/living/climate"

COLORS = {"ON": "#2e7d32", "OFF": "#37474f", "UNKNOWN": "#5d4037", "OFFLINE": "#b71c1c"}

inbox = queue.Queue()


def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code.is_failure:
        inbox.put(("_conn", f"error: {reason_code}"))
        return
    subs = [(f"{PREFIX}/{d}/state", 1) for d, _ in DEVICES]
    subs += [(f"{PREFIX}/{d}/status", 1) for d, _ in DEVICES]
    subs.append((SENSOR_TOPIC, 0))
    client.subscribe(subs)
    inbox.put(("_conn", "connected"))


def on_disconnect(client, userdata, flags, reason_code, properties):
    inbox.put(("_conn", "reconnecting"))


def on_message(client, userdata, msg):
    inbox.put((msg.topic, msg.payload.decode(errors="replace")))


client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
                     client_id=CLIENT_ID, protocol=mqtt.MQTTv5)
if USERNAME:
    client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
    client.tls_set(cert_reqs=ssl.CERT_REQUIRED)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.reconnect_delay_set(min_delay=1, max_delay=30)
client.connect_async(HOST, PORT, keepalive=30)
client.loop_start()

# ---------- GUI ----------
root = tk.Tk()
root.title("Control Panel")
root.configure(bg="#121212")
root.attributes("-fullscreen", True)
root.bind("<Escape>", lambda e: root.destroy())

header = tk.Frame(root, bg="#121212")
header.pack(fill="x", padx=20, pady=(16, 8))
climate_lbl = tk.Label(header, text="-- °C  --%", font=("DejaVu Sans", 28),
                       fg="white", bg="#121212")
climate_lbl.pack(side="left")
conn_lbl = tk.Label(header, text="connecting", font=("DejaVu Sans", 16),
                    fg="#ffb300", bg="#121212")
conn_lbl.pack(side="right")

grid = tk.Frame(root, bg="#121212")
grid.pack(expand=True, fill="both", padx=20, pady=20)
for c in range(2):
    grid.columnconfigure(c, weight=1)
for r in range((len(DEVICES) + 1) // 2):
    grid.rowconfigure(r, weight=1)

buttons = {}
state = {d: "UNKNOWN" for d, _ in DEVICES}
online = {d: True for d, _ in DEVICES}


def send_toggle(dev):
    client.publish(f"{PREFIX}/{dev}/set", "TOGGLE", qos=1)
    buttons[dev].configure(text=f"{dict(DEVICES)[dev]}\n...")  # pending, not assumed


def render(dev):
    label = dict(DEVICES)[dev]
    if not online[dev]:
        text, color = f"{label}\nOFFLINE", COLORS["OFFLINE"]
    else:
        text, color = f"{label}\n{state[dev]}", COLORS.get(state[dev], COLORS["UNKNOWN"])
    buttons[dev].configure(text=text, bg=color, activebackground=color)


for i, (dev, label) in enumerate(DEVICES):
    b = tk.Button(grid, text=f"{label}\n--", font=("DejaVu Sans", 26, "bold"),
                  fg="white", bg=COLORS["UNKNOWN"], relief="flat", bd=0,
                  highlightthickness=0, command=lambda d=dev: send_toggle(d))
    b.grid(row=i // 2, column=i % 2, sticky="nsew", padx=10, pady=10)
    buttons[dev] = b


def pump_inbox():
    while True:
        try:
            topic, payload = inbox.get_nowait()
        except queue.Empty:
            break
        if topic == "_conn":
            conn_lbl.configure(text=payload,
                               fg="#66bb6a" if payload == "connected" else "#ffb300")
            continue
        if topic == SENSOR_TOPIC:
            try:
                d = json.loads(payload)
                climate_lbl.configure(text=f"{d.get('t', '--')} °C  {d.get('rh', '--')}%")
            except ValueError:
                pass
            continue
        parts = topic.split("/")
        dev, kind = parts[-2], parts[-1]
        if dev not in state:
            continue
        if kind == "state":
            state[dev] = payload.strip().upper()
        elif kind == "status":
            online[dev] = payload.strip().lower() == "online"
        render(dev)
    root.after(100, pump_inbox)


root.after(100, pump_inbox)
try:
    root.mainloop()
finally:
    client.loop_stop()
    client.disconnect()

A pressed button shows ... until the device confirms its new state. If no confirmation arrives, the button keeps showing the pending marker, which tells the user something is wrong instead of hiding it. Buttons for devices whose status topic says offline turn red. The window is full screen with no title bar; press Escape on a keyboard to quit while testing.

Designing a Panel People Can Actually Use

A control panel on a wall gets used by people in a hurry, often with wet or gloved hands, so a few layout rules matter more than visual polish:

  • Make targets big. Fingers are far less precise than a mouse. The grid above gives each device a quarter of the screen on a 7-inch display, which is about right. Avoid packing more than six or eight devices onto one page.
  • Use colour plus text. Green and grey communicate state quickly, but some users cannot tell colours apart. The button text always says ON, OFF or OFFLINE as well.
  • Show pending states honestly. The ... marker after a tap tells the user the command was sent and the panel is waiting for the device, which stops people tapping repeatedly and toggling a device twice.
  • Keep the connection indicator visible. If the panel itself is offline, every state on screen may be stale. The header label makes that obvious.
  • Prefer explicit commands for risky devices. TOGGLE is convenient for lights. For a heater or a door, separate ON and OFF buttons, or a confirmation step, prevent accidental activation.

Test with Simulated Devices

You can test the whole panel before any hardware exists. Open a terminal on another machine and pretend to be the hall light by publishing a retained state:

mosquitto_pub -h broker.connectmq.com -p 1883 -i sim-e40b77 -r -q 1 \
  -t connectmq/demo/e40b77/home/hall_light/state -m ON

The button turns green. Now watch the commands the panel sends:

mosquitto_sub -h broker.connectmq.com -p 1883 -i spy-e40b77 \
  -t 'connectmq/demo/e40b77/home/+/set' -v

Tap the hall light button and TOGGLE appears. Publish OFF to the state topic to complete the round trip. Remember that the public MQTT test broker is open to everyone, so anyone who guessed your topic prefix could press your buttons too. That is fine for a demo and unacceptable for a garage door.

Make the Panel Start on Boot

Set the Pi to boot to the desktop with auto-login (sudo raspi-config, System Options, Boot / Auto Login, Desktop Autologin) and disable screen blanking under Display Options if you want the panel always visible. Then add an autostart entry. On the labwc desktop used by current Bookworm images:

mkdir -p ~/.config/labwc
echo "/home/pi/panel-env/bin/python /home/pi/panel.py &" >> ~/.config/labwc/autostart

On older Bookworm builds that run Wayfire, add the same command under an [autostart] section in ~/.config/wayfire.ini. Replace pi with your username.

Securing a Raspberry Pi MQTT Display for Real Devices

A control panel publishes commands that move physical things, so it needs a private broker with authentication and encryption. A managed MQTT broker such as ConnectMQ provides exactly that without running broker software on the Pi. Create a broker in the portal, copy the generated username, password and client ID, and start the panel with:

export MQTT_PORT=8883
export MQTT_USERNAME=YOUR_USERNAME
export MQTT_PASSWORD=YOUR_PASSWORD
export MQTT_CLIENT_ID=YOUR_CLIENT_ID
export MQTT_PREFIX=home
/home/pi/panel-env/bin/python /home/pi/panel.py

Put the same variables in a small wrapper script for autostart. ConnectMQ binds each client ID to its credentials, so the panel and each device need separate credentials, which also means a leaked device password cannot be used to impersonate the panel. On the free plan QoS is capped at 1, which is what this panel uses.

Add safety at the device too. A garage door controller should ignore TOGGLE if its own sensors say something is in the way, regardless of who sent the command. The panel is a convenience, not a safety system.

Troubleshooting

Symptom Cause Fix
Window freezes after hours Widgets updated from MQTT thread Only touch widgets inside pump_inbox
Buttons stay brown (UNKNOWN) Device does not publish retained state Publish state with retain
Tap does nothing visible Device offline or wrong topic Watch the set topic with mosquitto_sub
_tkinter.TclError: no display Started from SSH without a desktop Use autostart, or export DISPLAY=:0
Connection flips constantly Same client ID used twice Each client needs a unique ID

Get Started with ConnectMQ

Before your panel controls anything real, move it to a private broker: sign up at app.connectmq.com for the free Serverless plan, no credit card needed, and use the portal's in-browser MQTT client to check your device topics. For a display-only screen, see the Raspberry Pi kiosk dashboard, and for the fundamentals behind this project, the Raspberry Pi MQTT tutorial.

Frequently Asked Questions

Why use Tkinter instead of a web page for the touchscreen?

Tkinter ships with Python, starts in a second and uses very little memory, which suits older Pis. A browser-based panel is also valid and easier to style if you already know HTML.

Can several panels control the same devices?

Yes. Each panel subscribes to the same retained state topics, so all of them stay in sync automatically, and each needs its own client ID.

What happens if the Pi loses network while a button is pressed?

The command may never reach the device, but the button keeps showing the pending marker until a state confirmation arrives. The user sees that nothing happened and can tap again once the panel shows connected.

Does Tkinter work with the Wayland desktop on Bookworm?

Yes, it runs through Xwayland automatically. Touch input works the same as mouse clicks for buttons.

Related articles

Pico W vs Pico 2 W for MQTT: RP2040 vs RP2350 Performance, Memory and TLS

The Pico 2 W keeps the Pico W's footprint and Wi-Fi chip but swaps the RP2040 for the faster RP2350. This comparison looks at what that means for MQTT devices: CPU, memory, TLS, security features, software support, and which board to buy for which project.

8 min read

Raspberry Pi RFID MQTT Access Logging with the RC522 Reader

Wire an RC522 RFID reader to a Raspberry Pi, read tags in Python, check them against a local allowlist and publish every access attempt as an MQTT event that loggers, dashboards and alerting tools can consume.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.