Pour tester les messages retenus, publiez avec le flag retain, connectez ensuite un abonné tout neuf et vérifiez qu'il reçoit immédiatement le message avec retain=1. Testez ensuite l'autre moitié du contrat : un second publish retenu écrase le premier, un publish non retenu ne le fait pas, et un publish retenu avec payload vide efface le topic, si bien que l'abonné suivant ne reçoit rien.
Ce guide parcourt chaque cas avec mosquitto_pub / mosquitto_sub, les transforme en suite pytest automatisée et couvre les options MQTT 5 qui modifient le comportement des messages retenus.
Ce qu'il faut vérifier
| # | Test | Résultat attendu |
|---|---|---|
| 1 | Publish retenu, puis un nouvel abonné | Message livré aussitôt, flag retain 1 |
| 2 | Un abonné déjà connecté reçoit un publish retenu | Livré, flag retain 0 (par défaut en MQTT 3.1.1) |
| 3 | Second publish retenu sur le même topic | Seule la dernière valeur est stockée |
| 4 | Publish non retenu sur le même topic | Valeur stockée inchangée |
| 5 | Publish retenu avec payload vide | Topic effacé ; les nouveaux abonnés ne reçoivent rien |
| 6 | Abonnement à wildcard sur des topics retenus | Un message retenu par topic correspondant |
| 7 | Redémarrage du broker | Les retenus ne survivent que si le broker les persiste |
Les bases sont expliquées dans les messages retenus MQTT. Utilisez une arborescence de topics unique comme testmqtt/retain-test/... pour ne pas lire l'état de quelqu'un d'autre sur un broker partagé — et nettoyez-la ensuite.
Test 1 : un abonné tardif reçoit la dernière valeur
mosquitto_pub -h broker.connectmq.com -p 1883 \
-t 'testmqtt/retain-test/door' -m 'closed' -r -q 1
# a brand-new subscriber, started after the publish
mosquitto_sub -h broker.connectmq.com -p 1883 \
-t 'testmqtt/retain-test/door' -C 1 -W 5 -F '%r %t %p'Sortie attendue : 1 testmqtt/retain-test/door closed. -F '%r %t %p' affiche le flag retain, le topic et le payload ; -C 1 s'arrête après un message et -W 5 abandonne au bout de cinq secondes, la commande ne reste donc jamais bloquée dans un script. Si l'abonné expire, le message n'a pas été stocké : vérifiez que vous avez bien passé -r, que votre ACL autorise la publication de messages retenus et que le broker prend en charge le retain (Retain not supported correspond au reason code 0x9A en MQTT 5).
Test 2 : la livraison en direct a retain=0
Démarrez d'abord un abonné et laissez-le tourner, puis publiez un message retenu :
# Terminal 1: existing subscriber
mosquitto_sub -h broker.connectmq.com -p 1883 -t 'testmqtt/retain-test/#' -F '%r %t %p'
# Terminal 2
mosquitto_pub -h broker.connectmq.com -p 1883 -t 'testmqtt/retain-test/door' -m 'open' -rLe terminal 1 affiche d'abord la valeur stockée avec 1, puis la nouvelle avec 0 testmqtt/retain-test/door open. C'est correct : en MQTT 3.1.1, le flag indique à l'abonné « ceci est un état stocké que vous recevez parce que vous vous êtes abonné », et non « le publisher a activé retain ». Un code qui s'appuie sur msg.retain pour distinguer l'ancien état des événements en direct fonctionne ; un code qui s'attend à voir le flag du publisher, non. En MQTT 5, abonnez-vous avec -V mqttv5 --retain-as-published et le terminal 1 affiche aussi 1 pour le message en direct.
Tests 3 à 5 : écraser, ignorer, effacer
B="-h broker.connectmq.com -p 1883"
T='testmqtt/retain-test/door'
mosquitto_pub $B -t "$T" -m 'open' -r # 3. overwrite "closed"
mosquitto_pub $B -t "$T" -m 'glitch' # 4. not retained: must not replace
mosquitto_sub $B -t "$T" -C 1 -W 5 -F '%r %p' # → 1 open
mosquitto_pub $B -t "$T" -r -n # 5. zero-length retained = clear
mosquitto_sub $B -t "$T" -C 1 -W 3 -F '%r %p' # → nothing, times out-n envoie un payload nul (de longueur zéro). Trois détails piègent souvent :
- Le topic doit correspondre exactement. Publier un message vide sur
testmqtt/retain-test/#est invalide : les wildcards sont interdits dans les topics de publication. Effacez chaque topic, ou utilisez la méthode en masse ci-dessous. - Le message vide est livré lui aussi. Les abonnés connectés à ce moment-là reçoivent un payload de longueur nulle. Assurez-vous que vos handlers ne plantent pas sur
b""(un parseur JSON plantera). - Un espace n'est pas vide.
-m ' 'stocke un message retenu d'un octet. Si un topic « ne s'efface pas », c'est une cause fréquente — voir le message retenu ne s'efface pas.
Test 6 : wildcards et nettoyage en masse
# list: prints only retained messages, stops at the first live one or after 3 s
mosquitto_sub -h broker.connectmq.com -p 1883 \
-t 'testmqtt/retain-test/#' --retained-only -W 3 -F '%t = %p'
# remove: publishes an empty retained message for every retained topic it receives
mosquitto_sub -h broker.connectmq.com -p 1883 \
-t 'testmqtt/retain-test/#' --retained-only --remove-retained -W 3Un abonnement à wildcard reçoit un message retenu par topic correspondant : s'abonner à un filtre large après un déploiement peut donc livrer des milliers de messages d'un coup. Vérifiez quels topics couvre un filtre avec le topic matcher avant de lancer --remove-retained — sur un broker de production, il supprime tout ce qui correspond.
L'automatiser avec pytest
Le même contrat sous forme de suite automatisée (paho-mqtt 2.x). Chaque test reçoit son propre topic aléatoire et la fixture le nettoie ensuite :
import threading, time, uuid
import paho.mqtt.client as mqtt
import pytest
HOST, PORT = "broker.connectmq.com", 1883
def connect():
ready = threading.Event()
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
# c.username_pw_set("username", "password")
c.on_connect = lambda cl, ud, flags, rc, props: ready.set()
c.connect(HOST, PORT)
c.loop_start()
assert ready.wait(5), "no CONNACK"
return c
def publish(topic, payload, retain=True):
c = connect()
c.publish(topic, payload, qos=1, retain=retain).wait_for_publish(5)
c.disconnect()
c.loop_stop()
def fetch(topic, wait=1.5):
"""Subscribe with a brand-new client; return [(payload, retain_flag), ...]."""
got = []
c = connect()
c.on_message = lambda cl, ud, msg: got.append((msg.payload, msg.retain))
c.subscribe(topic, qos=1)
time.sleep(wait)
c.disconnect()
c.loop_stop()
return got
@pytest.fixture
def topic():
t = f"testmqtt/retain-test/{uuid.uuid4().hex[:8]}"
yield t
publish(t, b"") # always clean up
def test_late_subscriber_gets_retained(topic):
publish(topic, b"closed")
assert fetch(topic) == [(b"closed", True)]
def test_overwrite_keeps_only_last(topic):
publish(topic, b"one")
publish(topic, b"two")
assert fetch(topic) == [(b"two", True)]
def test_non_retained_publish_does_not_replace(topic):
publish(topic, b"closed")
publish(topic, b"glitch", retain=False)
assert fetch(topic) == [(b"closed", True)]
def test_empty_payload_clears(topic):
publish(topic, b"closed")
publish(topic, b"")
assert fetch(topic) == []Le wait fixe garde l'exemple court ; en CI, remplacez-le par un événement déclenché au premier message, plus une courte marge. Pour des fixtures de broker réutilisables, voir fixtures pytest pour MQTT.
Les options MQTT 5 qui changent le résultat
MQTT 5 ajoute des options d'abonnement qu'un test complet doit couvrir (détails dans options d'abonnement) :
- Retain Handling —
0envoie les retenus à chaque subscribe (par défaut),1seulement si l'abonnement est nouveau,2jamais. - Retain As Published — conserve le flag retain du publisher pour les livraisons en direct (le test 2 affiche alors
1). - Message Expiry Interval — un message retenu avec expiration est supprimé à son expiration ; testez-le avec les tests d'expiration de messages.
import paho.mqtt.client as mqtt
from paho.mqtt.subscribeoptions import SubscribeOptions
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
c.on_message = lambda cl, ud, msg: print("got", msg.topic, msg.payload, msg.retain)
c.connect("broker.connectmq.com", 1883)
c.subscribe("testmqtt/retain-test/door",
options=SubscribeOptions(qos=1, retainHandling=SubscribeOptions.RETAIN_DO_NOT_SEND))
c.loop_forever() # prints only messages published from now onTest 7 : l'état retenu survit-il à un redémarrage ?
Les messages retenus vivent dans la mémoire du broker, sauf s'il les persiste sur disque. Sur un broker auto-hébergé, publiez une valeur retenue, redémarrez le broker et relancez le test 1. Si la valeur a disparu, activez la persistance (pour Mosquitto : persistence true et un persistence_location) ou acceptez que les appareils doivent republier leur état après un redémarrage du broker. Les brokers managés s'en chargent pour vous, mais le test vaut la peine d'être lancé une fois.
Inspectez les messages retenus dans le navigateur
Les messages retenus forment un petit contrat : stockés lors d'un publish avec retain, livrés une fois par nouvel abonnement, remplacés par le publish retenu suivant et effacés par un publish vide. Testez chaque clause une fois à la main avec le client MQTT en ligne ou mosquitto_sub -F '%r', puis gardez la suite pytest pour qu'un changement de broker ne casse jamais en silence le « dernier état connu » de vos appareils.
Questions fréquentes
Comment effacer un message retenu MQTT ?
Publiez un payload de longueur nulle avec le flag retain sur exactement le même topic, par exemple mosquitto_pub -t 'mon/topic' -r -n. Le broker supprime le message stocké. Impossible d'effacer avec un wildcard ; pour nettoyer toute une sous-arborescence, utilisez mosquitto_sub --remove-retained avec un abonnement à wildcard.
Pourquoi mon abonné voit-il retain=0 sur un message publié comme retenu ?
Parce qu'il était déjà abonné. En MQTT 3.1.1, le broker ne positionne le flag retain que sur les messages envoyés à cause d'un nouvel abonnement ; les livraisons en direct aux abonnés existants ont retain=0. En MQTT 5, vous pouvez conserver le flag d'origine avec l'option d'abonnement Retain As Published.
Un publish non retenu efface-t-il le message retenu ?
Non. Un publish normal est livré aux abonnés actuels mais laisse intact le message retenu stocké. Seul un autre publish retenu le remplace, et seul un publish retenu avec payload vide (ou l'expiration du message en MQTT 5) le supprime.

