Um display MQTT com Raspberry Pi é um painel touchscreen que mostra o que os seus dispositivos estão fazendo e permite controlá-los com um toque. Neste tutorial você vai montar um em Python com Tkinter e paho-mqtt: os botões publicam comandos, os rótulos se atualizam a partir dos topics de estado dos próprios dispositivos e o painel abre em tela cheia quando o Pi inicia. O padrão funciona com relés, lâmpadas, bombas ou qualquer outra coisa que fale MQTT.
Topics de comando vs. topics de estado
O erro mais comum em painéis de controle caseiros é mudar a cor do botão no momento em que ele é pressionado. Isso mostra o que você pediu, não o que realmente aconteceu. Se a placa de relés estiver offline, o painel mente.
O padrão robusto separa dois topics por dispositivo:
| Topic | Quem publica | Retained | Finalidade |
|---|---|---|---|
.../<device>/set |
O painel (ou qualquer controlador) | Não | Um pedido: ON, OFF, TOGGLE |
.../<device>/state |
O próprio dispositivo | Sim | A verdade: ON ou OFF |
.../<device>/status |
O dispositivo, mais o last will | Sim | online ou offline |
O painel publica em set e só muda a exibição quando chega uma mensagem em state. Como state é retained, o painel descobre o estado atual de cada dispositivo no momento em que assina, mesmo logo após um reboot. Os comandos propositalmente não são retained; caso contrário, um dispositivo que se reconecta repetiria um comando antigo. O guia de retained messages explica por que essa divisão importa.
Se você ainda não tem dispositivos, o tutorial relé no Raspberry Pi via MQTT monta o lado do dispositivo que este painel controla.
Hardware
- Raspberry Pi 4 ou 5, ou um Pi 3B+ para um painel simples.
- O Raspberry Pi Touch Display oficial (7 polegadas ou Touch Display 2) ou qualquer tela HDMI com touch USB.
- Raspberry Pi OS with desktop (Bookworm). O Tkinter roda no desktop Wayland via Xwayland sem configuração extra.
Instale as dependências
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"
A flag --system-site-packages permite que o ambiente virtual use o pacote Tkinter do sistema, que não pode ser instalado pelo pip.
A regra de threads que mantém o Tkinter estável
O loop_start() da paho-mqtt executa o tratamento de rede em uma thread em segundo plano, e os callbacks disparam nessa thread. O Tkinter não é thread-safe: mexer em widgets a partir de outra thread causa travamentos e falhas aleatórias, muitas vezes dias depois. A solução é uma fila. Os callbacks MQTT apenas colocam as mensagens em uma queue.Queue, e o loop principal do Tkinter esvazia a fila a cada 100 ms com root.after(). Todas as atualizações de widgets acontecem na thread da interface.
O código do display MQTT com Raspberry Pi
Salve 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...") # pendente, não presumido
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()
Um botão pressionado mostra ... até o dispositivo confirmar o novo estado. Se nenhuma confirmação chegar, o botão continua mostrando o marcador de pendência, o que avisa o usuário de que algo está errado em vez de esconder o problema. Botões de dispositivos cujo topic de status diz offline ficam vermelhos. A janela fica em tela cheia sem barra de título; pressione Escape em um teclado para sair durante os testes.
Projetando um painel que as pessoas conseguem usar
Um painel de controle na parede é usado por pessoas com pressa, muitas vezes com as mãos molhadas ou de luvas, então algumas regras de layout importam mais do que o acabamento visual:
- Faça alvos grandes. Dedos são muito menos precisos do que um mouse. A grade acima dá a cada dispositivo um quarto da tela em um display de 7 polegadas, o que é mais ou menos o ideal. Evite colocar mais de seis ou oito dispositivos em uma página.
- Use cor e texto. Verde e cinza comunicam o estado rapidamente, mas alguns usuários não distinguem cores. O texto do botão sempre diz também
ON,OFFouOFFLINE. - Mostre estados pendentes com honestidade. O marcador
...após um toque informa que o comando foi enviado e que o painel está esperando o dispositivo, o que evita toques repetidos que acionariam o dispositivo duas vezes. - Mantenha o indicador de conexão visível. Se o próprio painel estiver offline, todos os estados na tela podem estar desatualizados. O rótulo do cabeçalho deixa isso evidente.
- Prefira comandos explícitos para dispositivos de risco.
TOGGLEé prático para lâmpadas. Para um aquecedor ou um portão, botões separados deONeOFF, ou uma etapa de confirmação, evitam acionamentos acidentais.
Teste com dispositivos simulados
Você pode testar o painel inteiro antes de ter qualquer hardware. Abra um terminal em outra máquina e finja ser a luz do corredor publicando um 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
O botão fica verde. Agora acompanhe os comandos que o painel envia:
mosquitto_sub -h broker.connectmq.com -p 1883 -i spy-e40b77 \
-t 'connectmq/demo/e40b77/home/+/set' -v
Toque no botão da luz do corredor e TOGGLE aparece. Publique OFF no topic de estado para completar o ciclo. Lembre-se de que o servidor MQTT público é aberto a todos, então qualquer pessoa que adivinhasse o seu prefixo de topic poderia apertar os seus botões também. Isso é aceitável para uma demonstração e inaceitável para um portão de garagem.
Faça o painel iniciar no boot
Configure o Pi para iniciar no desktop com login automático (sudo raspi-config, System Options, Boot / Auto Login, Desktop Autologin) e desative o screen blanking em Display Options se quiser o painel sempre visível. Depois adicione uma entrada de autostart. No desktop labwc usado pelas imagens Bookworm atuais:
mkdir -p ~/.config/labwc
echo "/home/pi/panel-env/bin/python /home/pi/panel.py &" >> ~/.config/labwc/autostart
Em builds Bookworm mais antigas que rodam Wayfire, adicione o mesmo comando em uma seção [autostart] em ~/.config/wayfire.ini. Substitua pi pelo seu nome de usuário.
Protegendo um display MQTT com Raspberry Pi para dispositivos reais
Um painel de controle publica comandos que movem coisas físicas, então precisa de um broker privado com autenticação e criptografia. Um broker MQTT gerenciado como o ConnectMQ oferece exatamente isso sem rodar software de broker no Pi. Crie um broker no portal, copie o usuário, a senha e o client ID gerados e inicie o painel com:
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
Coloque as mesmas variáveis em um pequeno script wrapper para o autostart. O ConnectMQ vincula cada client ID às suas credenciais, então o painel e cada dispositivo precisam de credenciais separadas, o que também significa que uma senha de dispositivo vazada não pode ser usada para se passar pelo painel. No plano gratuito o QoS é limitado a 1, que é o que este painel usa.
Adicione segurança também no dispositivo. Um controlador de portão de garagem deve ignorar TOGGLE se os próprios sensores indicarem que há algo no caminho, independentemente de quem enviou o comando. O painel é uma conveniência, não um sistema de segurança.
Solução de problemas
| Sintoma | Causa | Solução |
|---|---|---|
| A janela trava depois de horas | Widgets atualizados pela thread do MQTT | Só mexa nos widgets dentro de pump_inbox |
Os botões ficam marrons (UNKNOWN) |
O dispositivo não publica estado retained | Publique o estado com retain |
| O toque não faz nada visível | Dispositivo offline ou topic errado | Acompanhe o topic set com mosquitto_sub |
_tkinter.TclError: no display |
Iniciado por SSH sem desktop | Use o autostart, ou export DISPLAY=:0 |
| A conexão alterna o tempo todo | Mesmo client ID usado duas vezes | Cada cliente precisa de um ID único |
Comece com o ConnectMQ
Antes que o seu painel controle algo real, migre-o para um broker privado: cadastre-se em app.connectmq.com no plano Serverless gratuito, sem cartão de crédito, e use o cliente MQTT no navegador do portal para conferir os topics dos seus dispositivos. Para uma tela só de exibição, veja o dashboard quiosque com Raspberry Pi e, para os fundamentos deste projeto, o tutorial MQTT com Raspberry Pi.
Perguntas frequentes
Por que usar Tkinter em vez de uma página web no touchscreen?
O Tkinter vem com o Python, inicia em um segundo e usa pouquíssima memória, o que combina com Pis mais antigos. Um painel baseado em navegador também é válido e mais fácil de estilizar se você já conhece HTML.
Vários painéis podem controlar os mesmos dispositivos?
Sim. Cada painel assina os mesmos topics de estado retained, então todos ficam sincronizados automaticamente, e cada um precisa do seu próprio client ID.
O que acontece se o Pi perder a rede enquanto um botão é pressionado?
O comando pode nunca chegar ao dispositivo, mas o botão continua mostrando o marcador de pendência até chegar uma confirmação de estado. O usuário vê que nada aconteceu e pode tocar de novo quando o painel mostrar que está conectado.
O Tkinter funciona com o desktop Wayland no Bookworm?
Sim, ele roda via Xwayland automaticamente. Para os botões, o toque funciona igual a um clique de mouse.



