Une architecture ESP32 FreeRTOS MQTT place l'échantillonnage des capteurs et la publication réseau dans des tâches distinctes, de sorte qu'une reconnexion Wi-Fi lente ne retarde jamais une mesure. Dans ce tutoriel, vous allez créer un sketch avec une tâche capteur, une tâche MQTT et une queue entre les deux, le tester avec le serveur MQTT public gratuit broker.connectmq.com, puis le passer en TLS avec vos propres identifiants.
Pourquoi utiliser des tâches FreeRTOS pour MQTT sur l'ESP32
La plupart des sketches de débutant font tout dans loop() : lire le capteur, vérifier le Wi-Fi, appeler client.loop(), publier, puis delay(). Cela fonctionne jusqu'à ce que le réseau se comporte mal. Un appel connect() vers un broker injoignable peut bloquer plusieurs secondes, et pendant ce temps rien n'est échantillonné. Si vous comptez des impulsions, moyennez un signal analogique ou surveillez un seuil, ces trous comptent.
Le core Arduino de l'ESP32 tourne déjà sur FreeRTOS. loop() n'est elle-même qu'une tâche épinglée sur le cœur 1. Rien ne vous empêche de créer d'autres tâches, et dès que vous le faites, le timing d'un travail ne dépend plus de l'autre :
- La tâche capteur se réveille à période fixe avec
vTaskDelayUntil(), lit le matériel et dépose une petite structure dans une queue. Elle ne touche jamais au réseau. - La tâche MQTT possède le Wi-Fi et le client MQTT. Elle vide la queue, publie, gère les keep-alives et se reconnecte si nécessaire.
- La queue est le seul objet partagé. Les queues FreeRTOS copient les données et s'utilisent sans risque entre tâches : vous n'avez besoin d'aucun mutex pour les mesures.
Une règle rend cette architecture robuste : une seule tâche appelle le client MQTT. PubSubClient n'est pas thread-safe, et appeler publish() depuis deux tâches à la fois corrompt son buffer interne. Faites plutôt tout transiter par la queue.
Ce dont vous avez besoin
- N'importe quelle carte ESP32 à deux cœurs (ESP32 DevKit, ESP32-S3). Les puces monocœur comme l'ESP32-C3 ou l'ESP32-C6 fonctionnent aussi si vous épinglez les deux tâches sur le cœur 0.
- Arduino IDE 2.x ou PlatformIO avec le core esp32 3.x.
- La bibliothèque PubSubClient de Nick O'Leary.
- Facultatif : n'importe quel capteur. Le sketch simule température et humidité pour fonctionner sur une carte nue.
mosquitto_subsur votre ordinateur pour les tests.
Répartition des tâches et priorités
| Tâche | Cœur | Priorité | Pile | Rôle |
|---|---|---|---|---|
sensorTask |
1 | 3 | 3072 octets | Échantillonner chaque seconde, pousser dans la queue |
mqttTask |
0 | 2 | 6144 octets | Wi-Fi, connexion MQTT, publication, keep-alive |
loop() Arduino |
1 | 1 | par défaut | Supprimée, inutile |
La tâche capteur reçoit la priorité la plus haute car elle est courte et critique en temps : elle s'exécute, met un élément en queue et se rendort. La tâche MQTT tourne sur le cœur 0 aux côtés de la pile Wi-Fi, qui s'y trouve aussi, et effectue le travail lent. Dans le core Arduino de l'ESP32, les tailles de pile s'expriment en octets, et non en mots comme dans FreeRTOS standard.
Le sketch ESP32 FreeRTOS MQTT complet
Remplacez les identifiants Wi-Fi et la partie aléatoire du préfixe de topic, puis téléversez.
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
// ---------- configuration ----------
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* TOPIC_PREFIX = "connectmq/demo/k3v9q1"; // rendez-le unique
// ---------- objets partagés ----------
struct Reading {
uint32_t seq;
uint32_t uptimeMs;
float temperature;
float humidity;
};
QueueHandle_t readingQueue;
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
// ---------- tâche capteur ----------
void sensorTask(void* param) {
const TickType_t period = pdMS_TO_TICKS(1000);
TickType_t lastWake = xTaskGetTickCount();
uint32_t seq = 0;
for (;;) {
Reading r;
r.seq = seq++;
r.uptimeMs = millis();
// Remplacez par une vraie lecture de capteur (DHT22, BME280, ADC...)
r.temperature = 21.0f + (esp_random() % 400) / 100.0f;
r.humidity = 45.0f + (esp_random() % 1000) / 100.0f;
// Ne pas bloquer : si la queue est pleine, retirer l'élément le plus ancien et réessayer
if (xQueueSend(readingQueue, &r, 0) != pdTRUE) {
Reading discarded;
xQueueReceive(readingQueue, &discarded, 0);
xQueueSend(readingQueue, &r, 0);
}
vTaskDelayUntil(&lastWake, period);
}
}
// ---------- fonctions MQTT (appelées uniquement depuis mqttTask) ----------
bool ensureWifi() {
if (WiFi.status() == WL_CONNECTED) return true;
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) {
vTaskDelay(pdMS_TO_TICKS(250));
}
return WiFi.status() == WL_CONNECTED;
}
bool ensureMqtt() {
if (mqtt.connected()) return true;
String willTopic = String(TOPIC_PREFIX) + "/status";
bool ok = mqtt.connect(clientId.c_str(), willTopic.c_str(), 1, true, "offline");
if (ok) {
mqtt.publish(willTopic.c_str(), "online", true);
}
return ok;
}
// ---------- tâche MQTT ----------
void mqttTask(void* param) {
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
String topic = String(TOPIC_PREFIX) + "/telemetry";
uint32_t backoffMs = 1000;
for (;;) {
if (!ensureWifi() || !ensureMqtt()) {
vTaskDelay(pdMS_TO_TICKS(backoffMs));
backoffMs = min<uint32_t>(backoffMs * 2, 30000);
continue;
}
backoffMs = 1000;
mqtt.loop();
Reading r;
// Attendre brièvement des données pour que la tâche libère le CPU au repos
if (xQueueReceive(readingQueue, &r, pdMS_TO_TICKS(50)) == pdTRUE) {
char payload[160];
snprintf(payload, sizeof(payload),
"{\"seq\":%lu,\"uptimeMs\":%lu,\"temperature\":%.2f,\"humidity\":%.2f,\"queued\":%u}",
(unsigned long)r.seq, (unsigned long)r.uptimeMs,
r.temperature, r.humidity,
(unsigned)uxQueueMessagesWaiting(readingQueue));
if (!mqtt.publish(topic.c_str(), payload)) {
// Le remettre en tête de queue et réessayer après reconnexion
xQueueSendToFront(readingQueue, &r, 0);
}
}
}
}
void setup() {
Serial.begin(115200);
clientId = "esp32-rtos-" + String((uint32_t)ESP.getEfuseMac(), HEX);
readingQueue = xQueueCreate(60, sizeof(Reading)); // une minute de tampon
if (readingQueue == nullptr) {
Serial.println("Queue allocation failed");
while (true) delay(1000);
}
xTaskCreatePinnedToCore(sensorTask, "sensor", 3072, nullptr, 3, nullptr, 1);
xTaskCreatePinnedToCore(mqttTask, "mqtt", 6144, nullptr, 2, nullptr, 0);
}
void loop() {
vTaskDelete(nullptr); // la tâche loop d'Arduino n'est pas nécessaire
}
Observez la sortie depuis votre ordinateur :
mosquitto_sub -h broker.connectmq.com -p 1883 -i sub-k3v9q1 -t "connectmq/demo/k3v9q1/#" -v
Vous devriez voir un message status valant online et un message de télémétrie par seconde. Débranchez maintenant votre routeur pendant vingt secondes. Le champ queued augmente tant que la carte est hors ligne, et au retour de la connexion la tâche MQTT vide l'arriéré dans l'ordre. C'est tout l'intérêt de cette architecture : les mesures ont été prises à l'heure même si la publication était bloquée.
La zone publique du broker est lisible par tout le monde : gardez donc des données de démonstration génériques. Elle limite aussi chaque client à 10 messages par seconde, seuil que le vidage d'un arriéré peut atteindre. Si vous mettez beaucoup de mesures en tampon, ajoutez un court vTaskDelay(pdMS_TO_TICKS(120)) après chaque publication tant que uxQueueMessagesWaiting() est élevé. Le guide du serveur MQTT public gratuit liste les autres limites.
Comment fonctionnent les différents éléments
La queue comme tampon
xQueueCreate(60, sizeof(Reading)) alloue 60 emplacements de taille fixe sur le tas. Chaque xQueueSend copie la structure de 16 octets, si bien que la tâche capteur peut réutiliser sa variable locale immédiatement. Dimensionner la queue est un compromis entre la RAM et la durée de coupure que vous voulez absorber. Soixante emplacements de 16 octets représentent moins de 1 Ko ; même quelques milliers d'emplacements tiennent facilement sur un ESP32 classique, mais n'oubliez pas que les données sont perdues au redémarrage.
Quand la queue est pleine, le sketch abandonne la mesure la plus ancienne plutôt que la plus récente. Pour de la télémétrie, la dernière valeur est généralement la plus utile. Si chaque échantillon compte, bloquez plutôt quelques ticks, ou agrégez les mesures (minimum, maximum, moyenne) avant de les mettre en queue.
vTaskDelayUntil ou delay
vTaskDelayUntil() réveille la tâche à un rythme fixe mesuré depuis le réveil précédent, de sorte que la période ne dérive pas de la durée de la lecture du capteur. Un simple delay(1000) après une lecture de 30 ms donne une période de 1,03 s qui glisse lentement. Les deux fonctions libèrent le CPU, contrairement à une boucle active basée sur millis().
Reconnexion avec backoff
La tâche MQTT réessaie avec un backoff exponentiel plafonné à 30 secondes. Bombarder le broker de tentatives immédiates gaspille de l'énergie et, sur un service managé, peut déclencher la protection anti-flood ou le blocage d'IP après échecs répétés. Le guide ESP32 MQTT Last Will explique pourquoi le message will offline en retained message est enregistré à la connexion, afin que les tableaux de bord repèrent un appareil hors service.
Pièges et dépannage
- Guru Meditation « Stack canary watchpoint triggered ». La pile de la tâche est trop petite.
snprintfavec des flottants et TLS ont tous deux besoin de marge. AffichezuxTaskGetStackHighWaterMark(nullptr)dans chaque tâche pendant le développement ; la fonction renvoie le minimum d'octets libres jamais observé. Gardez au moins 512 octets de réserve. - Réinitialisations par le task watchdog. Une tâche qui boucle sans jamais se bloquer affame la tâche idle de son cœur. Chaque itération doit appeler quelque chose qui cède la main :
vTaskDelay,xQueueReceiveavec un timeout, ou un appel socket bloquant. - Sortie série brouillée. Deux tâches qui écrivent en même temps entremêlent leurs caractères. Écrivez depuis une seule tâche ou protégez
Serialavec un mutex créé parxSemaphoreCreateMutex(). - Publier depuis la tâche capteur. Cela semblera fonctionner, puis échouera aléatoirement sous charge. Gardez le client MQTT dans une seule tâche.
- Les callbacks s'exécutent dans la tâche MQTT. Si vous vous abonnez à des commandes, le callback de PubSubClient s'exécute dans
mqtt.loop(). N'y faites pas de travail lent ; poussez la commande dans une seconde queue traitée par une autre tâche. - JSON volumineux tronqué. Le buffer par défaut de PubSubClient fait 256 octets, topic compris. Le sketch l'augmente avec
setBufferSize(512).
Passer à TLS et à votre propre broker
La zone publique est idéale pour les tests, mais les appareils de production doivent s'authentifier et chiffrer. Créez un compte gratuit sur app.connectmq.com, créez un broker et copiez le nom d'utilisateur, le mot de passe et le client ID générés. Modifiez ensuite uniquement la partie réseau de la tâche MQTT :
#include <WiFiClientSecure.h>
// ISRG Root X1, la racine Let's Encrypt. Collez le PEM publié par Let's Encrypt.
static const char ROOT_CA[] = R"PEM(
-----BEGIN CERTIFICATE-----
...paste ISRG Root X1 here...
-----END CERTIFICATE-----
)PEM";
WiFiClientSecure net;
PubSubClient mqtt(net);
// dans mqttTask, avant la boucle :
net.setCACert(ROOT_CA);
mqtt.setServer("broker.connectmq.com", 8883);
// dans ensureMqtt() :
mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD",
willTopic.c_str(), 1, true, "offline");
Augmentez la pile de la tâche MQTT à environ 8192 octets, car la négociation TLS consomme nettement plus de pile et de tas. Sur ConnectMQ, le client ID est lié aux identifiants émis : utilisez exactement celui fourni par le portail. Une fois les données en circulation, ouvrez l'explorateur de topics en direct du portail pour confirmer l'arrivée des messages et définissez une règle d'alerte à seuil sur le champ de température.
FreeRTOS sur l'ESP32 est documenté en détail dans la référence FreeRTOS d'ESP-IDF, y compris les différences SMP par rapport au FreeRTOS amont. Pour une vue plus large des projets d'appareils, le tutoriel IoT ESP32 rassemble le reste de cette série, et ajouter des horodatages NTP à la structure Reading est une étape suivante naturelle pour que les échantillons mis en tampon portent leur véritable heure de capture.
Démarrer avec ConnectMQ
Un sketch à base de tâches n'est que la moitié d'un système fiable ; l'autre moitié est un broker qui reste disponible pendant que vos appareils se reconnectent. ConnectMQ est un broker MQTT managé avec MQTT 3.1.1 et 5.0, TLS sur le port 8883 et un portail qui affiche vos appareils et topics en direct. Commencez avec l'offre gratuite Serverless sans carte bancaire, puis passez à une offre payante ou dédiée lorsque votre parc dépasse 25 connexions. Une fois le firmware solide, le même modèle s'applique à de vrais capteurs, comme dans le projet ESP32 DHT22.
Questions fréquentes
Puis-je appeler PubSubClient depuis plusieurs tâches FreeRTOS ?
Pas en toute sécurité. PubSubClient possède un buffer partagé unique et aucun verrouillage : des appels concurrents corrompent les paquets. Gardez-le dans une seule tâche et transmettez-lui les données via une queue.
Sur quel cœur faire tourner la tâche MQTT ?
Le cœur 0 est un bon choix par défaut, car les tâches Wi-Fi et lwIP y tournent déjà. Sur les puces monocœur comme l'ESP32-C3 ou l'ESP32-C6, épinglez toutes les tâches sur le cœur 0 ou utilisez xTaskCreate sans épinglage.
Quelle taille donner à la queue des mesures ?
Dimensionnez-la pour la plus longue coupure que vous voulez absorber. Multipliez la fréquence d'échantillonnage par la durée de la coupure et par la taille de la structure, et restez bien en dessous du tas libre indiqué par ESP.getFreeHeap().
Cela fonctionne-t-il avec AsyncMqttClient ?
Oui, mais AsyncMqttClient exécute déjà sa propre tâche réseau et ses callbacks : vous n'avez donc besoin que de la tâche capteur et d'une queue. Publiez depuis la tâche qui vide la queue, pas depuis les callbacks de la bibliothèque.



