Un écran Raspberry Pi MQTT est un panneau tactile qui affiche ce que font vos équipements et vous permet de les commander d'une simple pression. Dans ce tutoriel, vous allez en construire un en Python avec Tkinter et paho-mqtt : les boutons publient des commandes, les libellés se mettent à jour à partir des topics d'état des équipements eux-mêmes, et le panneau se lance en plein écran au démarrage du Pi. Ce modèle fonctionne avec des relais, des lampes, des pompes ou tout autre équipement qui parle MQTT, et il s'intègre naturellement dans une maison connectée. Jeedom et Domoticz savent aussi communiquer en MQTT, ce qui permet de faire cohabiter ce panneau avec votre box domotique.
Topics de commande et topics d'état
L'erreur la plus fréquente dans les panneaux de commande faits maison consiste à changer la couleur du bouton dès qu'on appuie dessus. Cela montre ce que vous avez demandé, pas ce qui s'est réellement passé. Si la carte relais est hors ligne, le panneau ment.
Le modèle robuste sépare deux topics par équipement :
| Topic | Qui publie | Retained | Rôle |
|---|---|---|---|
.../<device>/set |
Le panneau (ou tout contrôleur) | Non | Une demande : ON, OFF, TOGGLE |
.../<device>/state |
L'équipement lui-même | Oui | La vérité : ON ou OFF |
.../<device>/status |
L'équipement, plus le last will | Oui | online ou offline |
Le panneau publie sur set et ne modifie son affichage que lorsqu'un message arrive sur state. Comme state est retained, le panneau connaît l'état actuel de chaque équipement dès qu'il s'abonne, même juste après un redémarrage. Les commandes ne sont volontairement pas retained : sinon, un équipement qui se reconnecte rejouerait une ancienne commande. Le guide des retained messages explique pourquoi cette séparation est importante.
Si vous n'avez pas encore d'équipements, le tutoriel relais Raspberry Pi via MQTT construit le côté équipement que ce panneau commande.
Matériel
- Raspberry Pi 4 ou 5, ou un Pi 3B+ pour un panneau simple.
- L'écran tactile officiel Raspberry Pi (7 pouces ou Touch Display 2) ou n'importe quel écran HDMI avec tactile USB.
- Raspberry Pi OS avec bureau (Bookworm). Tkinter fonctionne sur le bureau Wayland via Xwayland sans configuration supplémentaire.
Installer les dépendances
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"
L'option --system-site-packages permet à l'environnement virtuel d'utiliser le paquet Tkinter du système, qui ne s'installe pas via pip.
La règle de threading qui garde Tkinter stable
La méthode loop_start() de paho-mqtt gère le réseau dans un thread d'arrière-plan, et ses callbacks s'exécutent sur ce thread. Tkinter n'est pas thread-safe : manipuler des widgets depuis un autre thread provoque des gels et des plantages aléatoires, souvent plusieurs jours plus tard. La solution est une file d'attente. Les callbacks MQTT se contentent de pousser les messages dans une queue.Queue, et la boucle principale de Tkinter vide cette file toutes les 100 ms avec root.after(). Toutes les mises à jour de widgets ont lieu sur le thread de l'interface graphique.
Le code de l'écran Raspberry Pi MQTT
Enregistrez ce fichier sous ~/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...") # en attente, pas supposé
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 bouton pressé affiche ... jusqu'à ce que l'équipement confirme son nouvel état. Si aucune confirmation n'arrive, le bouton conserve ce marqueur d'attente, ce qui signale à l'utilisateur qu'un problème existe au lieu de le masquer. Les boutons des équipements dont le topic de statut indique offline deviennent rouges. La fenêtre est en plein écran, sans barre de titre ; appuyez sur Échap sur un clavier pour quitter pendant vos tests.
Concevoir un panneau réellement utilisable
Un panneau de commande mural est utilisé par des personnes pressées, souvent avec les mains mouillées ou gantées : quelques règles de mise en page comptent donc davantage que le soin visuel.
- Prévoyez de grandes zones tactiles. Un doigt est bien moins précis qu'une souris. La grille ci-dessus attribue à chaque équipement un quart de l'écran sur un affichage de 7 pouces, ce qui est à peu près idéal. Évitez de placer plus de six à huit équipements sur une même page.
- Associez couleur et texte. Le vert et le gris communiquent rapidement un état, mais certains utilisateurs distinguent mal les couleurs. Le texte du bouton indique toujours aussi
ON,OFFouOFFLINE. - Affichez honnêtement les états en attente. Le marqueur
...après une pression indique que la commande a été envoyée et que le panneau attend l'équipement, ce qui évite les pressions répétées qui basculent un équipement deux fois. - Gardez l'indicateur de connexion visible. Si le panneau lui-même est hors ligne, tous les états affichés peuvent être périmés. Le libellé de l'en-tête le rend évident.
- Préférez des commandes explicites pour les équipements à risque.
TOGGLEest pratique pour les lampes. Pour un radiateur ou une porte, des boutonsONetOFFséparés, ou une étape de confirmation, évitent les activations accidentelles.
Tester avec des équipements simulés
Vous pouvez tester tout le panneau avant même d'avoir du matériel. Ouvrez un terminal sur une autre machine et faites semblant d'être la lampe de l'entrée en publiant un état 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
Le bouton devient vert. Observez maintenant les commandes envoyées par le panneau :
mosquitto_sub -h broker.connectmq.com -p 1883 -i spy-e40b77 \
-t 'connectmq/demo/e40b77/home/+/set' -v
Appuyez sur le bouton de la lampe de l'entrée et TOGGLE apparaît. Publiez OFF sur le topic d'état pour boucler l'aller-retour. N'oubliez pas que le serveur MQTT public gratuit est ouvert à tous : quiconque devinerait votre préfixe de topic pourrait aussi appuyer sur vos boutons. C'est acceptable pour une démo, inacceptable pour une porte de garage.
Lancer le panneau au démarrage
Configurez le Pi pour démarrer sur le bureau avec connexion automatique (sudo raspi-config, System Options, Boot / Auto Login, Desktop Autologin) et désactivez la mise en veille de l'écran dans Display Options si vous voulez que le panneau reste toujours visible. Ajoutez ensuite une entrée de démarrage automatique. Sur le bureau labwc utilisé par les images Bookworm actuelles :
mkdir -p ~/.config/labwc
echo "/home/pi/panel-env/bin/python /home/pi/panel.py &" >> ~/.config/labwc/autostart
Sur les anciennes versions de Bookworm qui utilisent Wayfire, ajoutez la même commande dans une section [autostart] de ~/.config/wayfire.ini. Remplacez pi par votre nom d'utilisateur.
Sécuriser un écran Raspberry Pi MQTT pour de vrais équipements
Un panneau de commande publie des commandes qui font bouger des objets physiques : il lui faut un broker privé avec authentification et chiffrement. Un broker MQTT managé comme ConnectMQ offre exactement cela sans faire tourner de logiciel de broker sur le Pi. Créez un broker dans le portail, copiez le nom d'utilisateur, le mot de passe et le client ID générés, puis lancez le panneau avec :
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
Placez les mêmes variables dans un petit script d'enveloppe pour le démarrage automatique. ConnectMQ lie chaque client ID à ses identifiants : le panneau et chaque équipement ont donc besoin d'identifiants distincts, ce qui signifie aussi qu'un mot de passe d'équipement divulgué ne permet pas d'usurper le panneau. Sur l'offre gratuite, la QoS est plafonnée à 1, ce qui correspond à ce qu'utilise ce panneau.
Ajoutez aussi de la sécurité côté équipement. Un contrôleur de porte de garage doit ignorer TOGGLE si ses propres capteurs détectent un obstacle, quel que soit l'expéditeur de la commande. Le panneau est un confort, pas un système de sécurité.
Dépannage
| Symptôme | Cause | Solution |
|---|---|---|
| La fenêtre gèle après quelques heures | Widgets mis à jour depuis le thread MQTT | Ne toucher aux widgets que dans pump_inbox |
Les boutons restent marron (UNKNOWN) |
L'équipement ne publie pas d'état retained | Publier l'état avec retain |
| Une pression ne produit rien de visible | Équipement hors ligne ou mauvais topic | Surveiller le topic set avec mosquitto_sub |
_tkinter.TclError: no display |
Lancé depuis SSH sans bureau | Utiliser l'autostart, ou export DISPLAY=:0 |
| La connexion bascule sans cesse | Même client ID utilisé deux fois | Chaque client doit avoir un ID unique |
Démarrer avec ConnectMQ
Avant que votre panneau ne commande quoi que ce soit de réel, passez à un broker privé : inscrivez-vous sur app.connectmq.com pour l'offre gratuite Serverless (Free), sans carte bancaire, et utilisez le client MQTT intégré au navigateur du portail pour vérifier les topics de vos équipements. Pour un écran d'affichage seul, consultez le tableau de bord kiosque sur Raspberry Pi, et pour les bases de ce projet, le tutoriel MQTT sur Raspberry Pi.
Questions fréquentes
Pourquoi utiliser Tkinter plutôt qu'une page web pour l'écran tactile ?
Tkinter est fourni avec Python, démarre en une seconde et consomme très peu de mémoire, ce qui convient aux Pi plus anciens. Un panneau basé sur un navigateur est également valable, et plus facile à styliser si vous connaissez déjà le HTML.
Plusieurs panneaux peuvent-ils commander les mêmes équipements ?
Oui. Chaque panneau s'abonne aux mêmes topics d'état retained : tous restent donc synchronisés automatiquement, et chacun a besoin de son propre client ID.
Que se passe-t-il si le Pi perd le réseau au moment où l'on appuie sur un bouton ?
La commande risque de ne jamais atteindre l'équipement, mais le bouton conserve le marqueur d'attente jusqu'à l'arrivée d'une confirmation d'état. L'utilisateur voit que rien ne s'est passé et peut appuyer de nouveau une fois que le panneau affiche « connected ».
Tkinter fonctionne-t-il avec le bureau Wayland de Bookworm ?
Oui, il passe automatiquement par Xwayland. Pour les boutons, le tactile fonctionne exactement comme les clics de souris.



