Raspberry Pi

Pantalla MQTT con Raspberry Pi: crea un panel de control táctil en Python

Crea un panel de control táctil a pantalla completa en una Raspberry Pi. Los botones publican comandos MQTT, la pantalla refleja el estado real de los dispositivos a partir de retained messages y el panel arranca automáticamente al encender.

Pantalla táctil de Raspberry Pi con botones y estados de dispositivos como panel de control de una pantalla MQTT con Raspberry Pi
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

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, OFF u OFFLINE.
  • 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. TOGGLE es cómodo para las luces. Para un calefactor o una puerta, botones separados de ON y OFF, 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.

Artículos relacionados

Fiabilidad de la Raspberry Pi: cómo usar una Pi 24/7 como gateway IoT

Una guía práctica para mantener un gateway MQTT con Raspberry Pi funcionando durante meses: desgaste del almacenamiento, sistemas de archivos de solo lectura, alimentación, refrigeración, watchdogs de hardware y software y monitorización remota de la salud.

9 min de lectura

Pico W vs Pico 2 W para MQTT: rendimiento, memoria y TLS de RP2040 frente a RP2350

La Pico 2 W mantiene el formato y el chip Wi-Fi de la Pico W, pero sustituye el RP2040 por el RP2350, más rápido. Esta comparativa analiza qué significa para los dispositivos MQTT: CPU, memoria, TLS, funciones de seguridad, soporte de software y qué placa comprar para cada proyecto.

9 min de lectura

Registro de accesos Raspberry Pi RFID MQTT con el lector RC522

Conecta un lector RFID RC522 a una Raspberry Pi, lee etiquetas en Python, compáralas con una lista blanca local y publica cada intento de acceso como un evento MQTT que pueden consumir registradores, dashboards y herramientas de alertas.

8 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.