Una pantalla MQTT con Raspberry Pi es un panel táctil que muestra lo que hacen tus dispositivos y además te permite controlarlos con un toque. En este tutorial construirás uno en Python con Tkinter y paho-mqtt: los botones publican comandos, las etiquetas se actualizan desde los topics de estado de los propios dispositivos y el panel se abre a pantalla completa cuando arranca la Pi. El patrón funciona con relés, luces, bombas o cualquier otra cosa que hable MQTT.
Topics de comando frente a topics de estado
El error más común en los paneles de control caseros es cambiar el color del botón en el momento en que se pulsa. Eso muestra lo que pediste, no lo que realmente ocurrió. Si la placa de relés está desconectada, el panel miente.
El patrón robusto separa dos topics por dispositivo:
| Topic | Quién publica | Retained | Propósito |
|---|---|---|---|
.../<device>/set |
El panel (o cualquier controlador) | No | Una petición: ON, OFF, TOGGLE |
.../<device>/state |
El propio dispositivo | Sí | La verdad: ON u OFF |
.../<device>/status |
El dispositivo, más su last will | Sí | online u offline |
El panel publica en set y solo cambia lo que muestra cuando llega un mensaje en state. Como state es retained, el panel conoce el estado actual de cada dispositivo en cuanto se suscribe, incluso justo después de un reinicio. Los comandos no son retained a propósito; de lo contrario, un dispositivo que se reconecta repetiría un comando antiguo. La guía de retained messages explica por qué importa esta separación.
Si todavía no tienes dispositivos, el tutorial de relé con Raspberry Pi por MQTT construye el lado del dispositivo que controla este panel.
Hardware
- Raspberry Pi 4 o 5, o una Pi 3B+ para un panel sencillo.
- La pantalla táctil oficial de Raspberry Pi (de 7 pulgadas o Touch Display 2) o cualquier pantalla HDMI con táctil USB.
- Raspberry Pi OS with desktop (Bookworm). Tkinter funciona en el escritorio Wayland a través de Xwayland sin configuración adicional.
Instala las dependencias
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"
El flag --system-site-packages permite que el entorno virtual use el paquete Tkinter del sistema, que no se puede instalar desde pip.
La regla de hilos que mantiene estable a Tkinter
loop_start() de paho-mqtt ejecuta la gestión de red en un hilo en segundo plano, y sus callbacks se disparan en ese hilo. Tkinter no es thread-safe: tocar widgets desde otro hilo provoca bloqueos y cierres aleatorios, a menudo días después. La solución es una cola. Los callbacks MQTT solo meten mensajes en una queue.Queue, y el bucle principal de Tkinter vacía la cola cada 100 ms con root.after(). Todas las actualizaciones de widgets ocurren en el hilo de la interfaz.
El código de la pantalla MQTT con Raspberry Pi
Guárdalo como ~/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...") # pendiente, no se da por hecho
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()
Un botón pulsado muestra ... hasta que el dispositivo confirma su nuevo estado. Si no llega ninguna confirmación, el botón sigue mostrando el marcador de pendiente, lo que indica al usuario que algo va mal en lugar de ocultarlo. Los botones de dispositivos cuyo topic de estado de conexión dice offline se vuelven rojos. La ventana ocupa toda la pantalla sin barra de título; pulsa Escape en un teclado para salir mientras pruebas.
Cómo diseñar un panel que la gente pueda usar de verdad
Un panel de control en la pared lo usa gente con prisa, a menudo con las manos mojadas o con guantes, así que unas pocas reglas de diseño importan más que el acabado visual:
- Haz los objetivos grandes. Los dedos son mucho menos precisos que un ratón. La cuadrícula de arriba da a cada dispositivo un cuarto de la pantalla en un display de 7 pulgadas, que es lo adecuado. Evita poner más de seis u ocho dispositivos en una misma página.
- Usa color y texto. El verde y el gris comunican el estado rápidamente, pero algunos usuarios no distinguen los colores. El texto del botón siempre dice además
ON,OFFuOFFLINE. - Muestra los estados pendientes con honestidad. El marcador
...tras un toque indica al usuario que el comando se envió y que el panel espera al dispositivo, lo que evita que la gente pulse repetidamente y conmute un dispositivo dos veces. - Mantén visible el indicador de conexión. Si el propio panel está desconectado, todos los estados en pantalla pueden estar desactualizados. La etiqueta de la cabecera lo deja claro.
- Prefiere comandos explícitos para dispositivos de riesgo.
TOGGLEes cómodo para las luces. Para un calefactor o una puerta, botones separados deONyOFF, o un paso de confirmación, evitan activaciones accidentales.
Prueba con dispositivos simulados
Puedes probar todo el panel antes de que exista ningún hardware. Abre una terminal en otro equipo y finge ser la luz del recibidor publicando un estado retained:
mosquitto_pub -h broker.connectmq.com -p 1883 -i sim-e40b77 -r -q 1 \
-t connectmq/demo/e40b77/home/hall_light/state -m ON
El botón se vuelve verde. Ahora observa los comandos que envía el panel:
mosquitto_sub -h broker.connectmq.com -p 1883 -i spy-e40b77 \
-t 'connectmq/demo/e40b77/home/+/set' -v
Toca el botón de la luz del recibidor y aparecerá TOGGLE. Publica OFF en el topic de estado para completar el ciclo. Recuerda que el servidor MQTT público gratuito está abierto a todo el mundo, así que cualquiera que adivine tu prefijo de topics también podría pulsar tus botones. Eso está bien para una demo y es inaceptable para la puerta de un garaje.
Haz que el panel arranque al encender
Configura la Pi para que arranque en el escritorio con inicio de sesión automático (sudo raspi-config, System Options, Boot / Auto Login, Desktop Autologin) y desactiva el apagado de pantalla en Display Options si quieres que el panel esté siempre visible. Después añade una entrada de inicio automático. En el escritorio labwc que usan las imágenes actuales de Bookworm:
mkdir -p ~/.config/labwc
echo "/home/pi/panel-env/bin/python /home/pi/panel.py &" >> ~/.config/labwc/autostart
En compilaciones anteriores de Bookworm con Wayfire, añade el mismo comando en una sección [autostart] de ~/.config/wayfire.ini. Sustituye pi por tu nombre de usuario.
Cómo proteger una pantalla MQTT con Raspberry Pi para dispositivos reales
Un panel de control publica comandos que mueven cosas físicas, así que necesita un broker privado con autenticación y cifrado. Un broker MQTT gestionado como ConnectMQ ofrece exactamente eso sin ejecutar software de broker en la Pi. Crea un broker en el portal, copia el nombre de usuario, la contraseña y el client ID generados e inicia el panel con:
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
Pon las mismas variables en un pequeño script envoltorio para el inicio automático. ConnectMQ vincula cada client ID a sus credenciales, así que el panel y cada dispositivo necesitan credenciales propias, lo que además significa que una contraseña filtrada de un dispositivo no sirve para suplantar al panel. En el plan gratuito el QoS está limitado a 1, que es el que usa este panel.
Añade también seguridad en el dispositivo. Un controlador de puerta de garaje debería ignorar TOGGLE si sus propios sensores indican que hay algo en medio, independientemente de quién haya enviado el comando. El panel es una comodidad, no un sistema de seguridad.
Solución de problemas
| Síntoma | Causa | Solución |
|---|---|---|
| La ventana se congela tras horas | Widgets actualizados desde el hilo MQTT | Toca los widgets solo dentro de pump_inbox |
Los botones siguen marrones (UNKNOWN) |
El dispositivo no publica un estado retained | Publica el estado con retain |
| Un toque no produce nada visible | Dispositivo desconectado o topic incorrecto | Observa el topic set con mosquitto_sub |
_tkinter.TclError: no display |
Iniciado por SSH sin escritorio | Usa el inicio automático, o export DISPLAY=:0 |
| La conexión cambia constantemente | El mismo client ID usado dos veces | Cada cliente necesita un ID único |
Empieza con ConnectMQ
Antes de que tu panel controle algo real, pásalo a un broker privado: regístrate en app.connectmq.com con el plan gratuito Serverless, sin tarjeta de crédito, y usa el cliente MQTT en el navegador del portal para comprobar los topics de tus dispositivos. Para una pantalla solo de visualización, consulta el dashboard Raspberry Pi en modo kiosko, y para los fundamentos de este proyecto, el tutorial de MQTT con Raspberry Pi.
Preguntas frecuentes
¿Por qué usar Tkinter en lugar de una página web para la pantalla táctil?
Tkinter viene con Python, arranca en un segundo y usa muy poca memoria, lo que le sienta bien a las Pi más antiguas. Un panel basado en navegador también es válido y más fácil de estilizar si ya sabes HTML.
¿Pueden varios paneles controlar los mismos dispositivos?
Sí. Cada panel se suscribe a los mismos topics de estado retained, así que todos se mantienen sincronizados automáticamente, y cada uno necesita su propio client ID.
¿Qué pasa si la Pi pierde la red mientras se pulsa un botón?
Puede que el comando nunca llegue al dispositivo, pero el botón sigue mostrando el marcador de pendiente hasta que llegue una confirmación de estado. El usuario ve que no ha pasado nada y puede volver a pulsar cuando el panel muestre que está conectado.
¿Funciona Tkinter con el escritorio Wayland de Bookworm?
Sí, se ejecuta automáticamente a través de Xwayland. La entrada táctil funciona igual que los clics de ratón para los botones.



