Arduino Avancé

Maîtriser les Architectures IoT Distribuées avec Arduino : De la Théorie aux Déploiements en Production

Explorez les mécanismes internes des plateformes IoT Arduino avancées et maîtrisez les patterns de communication, la gestion mémoire optimisée et les stratégies de déploiement critique. Un cours pour architectes IoT cherchant à dépasser les limites des prototypes.

Preparetoi.academy 30 min

Architecture des Systèmes IoT Multi-Nœuds avec Arduino

Définition: L'architecture multi-nœuds IoT est une infrastructure distribuée où plusieurs appareils Arduino communiquent via des protocoles standardisés (MQTT, CoAP, HTTP) pour former un écosystème cohérent de collecte, traitement et transmission de données vers un serveur central ou un cloud.

Explication détaillée: Dans une plateforme IoT avancée, chaque Arduino agit comme un nœud intelligent capable de fonctionner indépendamment tout en synchronisant ses actions avec d'autres appareils. L'architecture repose sur trois couches fondamentales : la couche capteur (acquisition locale), la couche communication (transmission réseau) et la couche d'intégration (serveurs backend). Les Arduino modernes supportent le WiFi (ESP32/ESP8266) ou les connexions cellulaires (Arduino MKR NB-IoT), permettant des déploiements à grande échelle. La complexité réside dans la gestion du timing distribué, la synchronisation des états, la détection des défaillances réseau et la persistance des données en cas de disconnexion. Les edge cases critiques incluent les pertes de paquets, les latences variables, les comportements zombie de connexion et les débordements de mémoire lors du stockage de données.

#include <WiFi.h>
#include <MQTT.h>
#include <ArduinoJson.h>

// Configuration IoT distribuée
class IoTNode {
  private:
    WiFiClient wifiClient;
    MQTTClient mqttClient;
    const char* nodeId = "ARDUINO_NODE_001";
    const char* brokerAddress = "mqtt.broker.io";
    unsigned long lastSyncTime = 0;
    const unsigned long SYNC_INTERVAL = 5000; // 5 secondes
    StaticJsonDocument<256> stateBuffer;
    
  public:
    IoTNode() : mqttClient(256) {}
    
    bool initializeNode(const char* ssid, const char* password) {
      // Connexion WiFi avec timeout
      WiFi.mode(WIFI_STA);
      WiFi.begin(ssid, password);
      
      int attempts = 0;
      while (WiFi.status() != WL_CONNECTED && attempts < 20) {
        delay(500);
        attempts++;
      }
      
      if (WiFi.status() != WL_CONNECTED) {
        return false; // Echec critique
      }
      
      // Configuration MQTT avec reconnexion automatique
      mqttClient.begin(brokerAddress, 1883, wifiClient);
      mqttClient.onMessage(messageHandler);
      
      return connectToBroker();
    }
    
    bool connectToBroker() {
      int maxAttempts = 5;
      int attempt = 0;
      
      while (!mqttClient.connect(nodeId) && attempt < maxAttempts) {
        delay(1000 * (attempt + 1)); // Backoff exponentiel
        attempt++;
      }
      
      if (mqttClient.connected()) {
        // Subscription aux topics de commande
        mqttClient.subscribe("cmd/" + String(nodeId) + "/#");
        mqttClient.subscribe("sync/broadcast");
        return true;
      }
      return false;
    }
    
    void publishSensorData(float temperature, float humidity, int voltage) {
      if (!mqttClient.connected()) {
        connectToBroker();
      }
      
      // Construction du JSON avec gestion erreur
      stateBuffer.clear();
      stateBuffer["nodeId"] = nodeId;
      stateBuffer["ts"] = millis();
      stateBuffer["temp"] = temperature;
      stateBuffer["humidity"] = humidity;
      stateBuffer["vbat"] = voltage;
      
      String payload;
      serializeJson(stateBuffer, payload);
      
      // Publication avec QoS 1 (au moins une fois)
      bool published = mqttClient.publish(
        "telemetry/" + String(nodeId),
        payload,
        false, // retain = false
        1      // QoS = 1
      );
      
      if (!published) {
        // Fallback : stockage local si publication échoue
        storeOfflineData(payload);
      }
      
      lastSyncTime = millis();
    }
    
    void messageHandler(String &topic, String &payload) {
      // Traitement des messages reçus
      if (topic.startsWith("cmd/")) {
        StaticJsonDocument<128> cmd;
        deserializeJson(cmd, payload);
        
        if (cmd.containsKey("action")) {
          String action = cmd["action"];
          executeCommand(action, cmd);
        }
      }
    }
    
    void executeCommand(String action, JsonDocument &params) {
      if (action == "reboot") {
        // Nettoyage avant reboot
        mqttClient.disconnect();
        delay(100);
        ESP.restart();
      } else if (action == "update_interval") {
        // Mise à jour dynamique d'intervalle
        // (avec validation stricte)
      }
    }
    
    void storeOfflineData(String payload) {
      // Implémentation SPIFFS/LittleFS
      // Détails spécifiques au modèle Arduino
    }
    
    void loop() {
      mqttClient.loop();
      
      // Reconnexion automatique avec stratégie
      if (!mqttClient.connected() && millis() - lastSyncTime > SYNC_INTERVAL) {
        connectToBroker();
      }
    }
};

// Utilisation
IoTNode node;

void setup() {
  Serial.begin(115200);
  delay(2000);
  
  if (!node.initializeNode("SSID", "PASSWORD")) {
    Serial.println("CRITICAL: Node initialization failed");
    // Gérer le scénario critique
  }
}

void loop() {
  // Simulation capteurs
  float temp = readTemperature();
  float humidity = readHumidity();
  int vbat = readBattery();
  
  node.publishSensorData(temp, humidity, vbat);
  node.loop();
  
  delay(5000);
}
Aspect Détail Remarque
Protocole MQTT Publish/Subscribe asynchrone Très efficace pour IoT, ~2KB overhead
Latence typique 50-500ms WiFi, 100-1000ms cellulaire Variable selon la bande passante
Scalabilité Jusqu'à 10K nœuds sur un broker standard Dépend du matériel serveur
Consommation RAM 32KB-256KB par nœud actif ESP32 optimal, Arduino Uno limité
QoS MQTT 0 (Fire&Forget), 1 (At least once), 2 (Exactly once) QoS 1 recommandé pour IoT

Astuce Performance: Utilisez une stratégie de backoff exponentiel pour les reconnexions : attendez 1s, puis 2s, 4s, 8s au lieu de réessayer immédiatement. Cela réduit la charge serveur de 90% lors de défaillances massives.

Attention Critique: Ne bloquez JAMAIS la boucle loop() avec des delay() prolongés. Le traitement MQTT doit être non-bloquant. Utilisez des timestamps (millis()) pour implémenter des délais logiques. Une exception : les délais lors de l'initialisation avant la boucle principale.


Optimisation Mémoire et Gestion des Ressources Limitées

Définition: L'optimisation mémoire en IoT Arduino consiste à minimiser l'empreinte RAM et Flash tout en maintenant les performances, en utilisant des structures de données efficaces, la compression et les stratégies de sérialisation optimisées.

Explication détaillée: Les microcontrôleurs Arduino ont des contraintes mémoire drastiques : 2KB-32KB de RAM selon le modèle, contre plusieurs GB sur des ordinateurs classiques. Chaque variable, chaque chaîne de caractères statique consomme cet espace critique. Dans une application IoT complexe, vous gérez potentiellement des buffers de communication, des files d'attente d'événements, des structures JSON et des logs. L'épuisement mémoire provoque des comportements imprévisibles : redémarrages aléatoires, corruption de données, comportement zombie (apparence de fonctionnement normal mais état interne corrompu). Les edge cases incluent les fuites mémoire (allocation sans libération), la fragmentation de l'heap, les débordements de pile et les conditions de concurrence. Les outils de debug comme l'analyse mémoire au runtime sont essentiels pour identifier les goulots d'étranglement.

#include <ArduinoJson.h>

// Analyse mémoire avancée
class MemoryManager {
  private:
    struct MemorySnapshot {
      unsigned long timestamp;
      int freeRam;
      int largestBlock;
    };
    
    static const int HISTORY_SIZE = 50;
    MemorySnapshot history[HISTORY_SIZE];
    int historyIndex = 0;
    
  public:
    // Fonction critère pour calculer la RAM libre
    int getFreeRam() {
      extern int __heap_start, *__brkval;
      int v;
      int free = (int) &v - (__brkval == 0 ?
        (int) &__heap_start : (int) __brkval);
      return free;
    }
    
    // Détection des fuites mémoire
    void recordSnapshot() {
      if (historyIndex >= HISTORY_SIZE) {
        historyIndex = 0; // Circular buffer
      }
      
      history[historyIndex].timestamp = millis();
      history[historyIndex].freeRam = getFreeRam();
      history[historyIndex].largestBlock = getLargestMemoryBlock();
      
      historyIndex++;
    }
    
    bool detectMemoryLeak() {
      // Détecter une baisse progressive de RAM
      if (historyIndex < 10) return false;
      
      int oldestRam = history[(historyIndex + 1) % HISTORY_SIZE].freeRam;
      int currentRam = getFreeRam();
      
      // Si la RAM a baissé de plus de 30% en 5 minutes
      unsigned long timespan = millis() - 
        history[(historyIndex + 1) % HISTORY_SIZE].timestamp;
      
      if (timespan > 300000 && (oldestRam - currentRam) > (oldestRam / 3)) {
        return true; // Fuite détectée
      }
      return false;
    }
    
    int getLargestMemoryBlock() {
      // Implémentation spécifique au MCU
      // Sur AVR: pas de fonction native, estimation par test
      for (int size = 1024; size > 0; size--) {
        void* ptr = malloc(size);
        if (ptr) {
          free(ptr);
          return size;
        }
      }
      return 0;
    }
    
    void printMemoryReport() {
      Serial.print(F("RAM libre: "));
      Serial.print(getFreeRam());
      Serial.print(F(" bytes | Plus grand bloc: "));
      Serial.println(getLargestMemoryBlock());
    }
};

// Stratégies d'optimisation

// 1. Utiliser const et PROGMEM pour les données statiques
const char MQTT_TOPIC[] PROGMEM = "sensor/temperature";
const char JSON_FORMAT[] PROGMEM = "{\"id\":%d,\"val\":%d}";

// 2. StaticJsonDocument au lieu de DynamicJsonDocument
void optimizedJsonHandling() {
  // MAUVAIS: allocation dynamique, risqué
  // DynamicJsonDocument doc(512);
  
  // BON: taille connue à la compilation
  StaticJsonDocument<256> doc;
  
  doc["sensor"] = "DHT22";
  doc["value"] = 25.5;
  
  // Sérialisation directe en chaîne (minimal overhead)
  char buffer[128];
  serializeJson(doc, buffer, sizeof(buffer));
  
  Serial.println(buffer);
}

// 3. String pool pour les identifiants constants
class StringPool {
  private:
    static constexpr const char* POOL[] = {
      "temp", "humidity", "pressure", "light", "motion"
    };
    static constexpr int POOL_SIZE = 5;
    
  public:
    const char* getPooledString(int index) {
      if (index >= 0 && index < POOL_SIZE) {
        return POOL[index];
      }
      return nullptr;
    }
};

// 4. Circular buffer pour les données temps réel
template<typename T, int SIZE>
class CircularBuffer {
  private:
    T data[SIZE];
    int writeIndex = 0;
    
  public:
    void push(T value) {
      data[writeIndex] = value;
      writeIndex = (writeIndex + 1) % SIZE;
    }
    
    T* getBuffer() {
      return data;
    }
    
    int getSize() {
      return SIZE;
    }
};

// Utilisation
CircularBuffer<float, 128> temperatureHistory; // 512 bytes fixe

// 5. Compresseur de données simples
class DataCompressor {
  public:
    // Compression: float 4 bytes -> int 2 bytes (perte mineure acceptable)
    int16_t compressTemperature(float temp) {
      // Range: -40 à +80°C avec précision 0.1°C
      // Formule: (temp + 40) * 10
      return (int16_t)((temp + 40.0) * 10.0);
    }
    
    float decompressTemperature(int16_t compressed) {
      return (compressed / 10.0) - 40.0;
    }
};

// 6. Diagnostic: FreeMemory tracker
volatile int minimumFreeRam = INT_MAX;

void trackMinimumMemory() {
  int currentFree = getFreeRam();
  if (currentFree < minimumFreeRam) {
    minimumFreeRam = currentFree;
    Serial.print(F("Nouveau minimum RAM: "));
    Serial.println(minimumFreeRam);
  }
}

int getFreeRam() {
  extern int __heap_start, *__brkval;
  int v;
  return (int) &v - (__brkval == 0 ?
    (int) &__heap_start : (int) __brkval);
}
Technique Gain Mémoire Complexité Cas d'usage
PROGMEM strings -50% RAM pour strings Très facile Toutes les constantes
StaticJsonDocument -30% vs Dynamic Faible JSON prévisible en taille
Circular buffers -70% vs vecteurs dynamiques Moyen Logs, historique capteurs
Data compression -50% payload MQTT Moyen Transmission reseau
StringPool -80% pour IDs répétés Moyen Nombreux identifiants

Astuce Performance: Mesurez la RAM initiale du sketch avec uniquement setup() vide pour connaître votre baseline. Ensuite, chaque allocation supplémentaire représente l'overhead réel. Utilisez Serial.println(F("texte")) (F macro) systématiquement pour garder les chaînes en Flash.

Attention Critique: Le heap (allocation dynamique) et la stack croissent l'un vers l'autre. Si le heap et la stack se rencontrent, corruption mémoire garantie avec des bugs impossibles à tracer. Jamais d'allocation dynamique en boucle principale sans libération correspondante. Les allocations récursives sont mortelles.


Protocoles de Communication Avancés et Synchronisation d'État Distribué

Définition: Les protocoles de communication avancés pour IoT incluent MQTT avec QoS variables, CoAP pour environnements contraints, et les mécanismes de synchronisation d'état qui garantissent la cohérence des données entre nœuds distribués malgré les défaillances réseau.

Explication détaillée: Dans un système IoT réel, la communication ne se résume pas à envoyer du JSON. Il faut gérer l'ordre des messages, la fiabilité, la congestion réseau, les retards variables et les défaillances totales de connectivité. MQTT offre trois niveaux QoS (Quality of Service) : QoS 0 (envoi simple, perte possible), QoS 1 (au moins une livraison, duplicatas possibles), QoS 2 (livraison exactement une fois, overhead maximal). CoAP est plus léger pour les réseaux 6LoWPAN et capteurs ultra-contraints. La synchronisation d'état distribuée résout le problème : quand un nœud se reconnecte après une déconnexion, quel état a-t-il manqué ? Les solutions incluent les timestamps causaux (Lamport clocks), les vecteurs de version et les strategies de réconciliation. Les edge cases critiques : messages en double, messages hors-ordre, partitions réseau, dérive d'horloge entre nœuds.

#include <MQTT.h>
#include <WiFi.h>
#include <ArduinoJson.h>

// Implémentation avancée de synchronisation d'état
class DistributedStateManager {
  private:
    struct StateVector {
      uint32_t version;
      uint32_t lamportClock;
      uint32_t lastUpdateTime;
      StaticJsonDocument<256> payload;
    };
    
    StateVector localState;
    StateVector remoteStates[5]; // Suivi de 5 nœuds distants max
    uint32_t lamportTimestamp = 0;
    MQTTClient mqttClient;
    
  public:
    DistributedStateManager() {
      localState.version = 1;
      localState.lamportClock = 0;
    }
    
    // Lamport Clock: horloge logique pour causalité distribuée
    void incrementLamportClock() {
      lamportTimestamp++;
    }
    
    void updateLamportClockFromMessage(uint32_t remoteTimestamp) {
      lamportTimestamp = max(lamportTimestamp, remoteTimestamp) + 1;
    }
    
    // Mise à jour d'état locale avec versionnage
    bool updateLocalState(const char* key, JsonVariant value) {
      // Incrémenter version et Lamport clock
      localState.version++;
      incrementLamportClock();
      localState.lamportClock = lamportTimestamp;
      localState.lastUpdateTime = millis();
      
      localState.payload[key] = value;
      
      // Propager à tous les nœuds
      return publishStateUpdate(key, value, localState.version);
    }
    
    bool publishStateUpdate(const char* key, JsonVariant value, uint32_t version) {
      // Construire message avec métadonnées
      StaticJsonDocument<256> msg;
      msg["key"] = key;
      msg["value"] = value;
      msg["version"] = version;
      msg["lamportClock"] = lamportTimestamp;
      msg["source"] = "NODE_001";
      msg["timestamp"] = millis();
      
      char buffer[256];
      serializeJson(msg, buffer, sizeof(buffer));
      
      // Publier avec QoS 1 (au moins une fois)
      return mqttClient.publish("state/updates", buffer, false, 1);
    }
    
    // Réconciliation d'état lors de reconnexion
    void reconcileStateOnReconnection() {
      Serial.println("Requesting state reconciliation...");
      
      // Stratégie: demander l'état complet du serveur
      StaticJsonDocument<128> request;
      request["type"] = "state_sync_request";
      request["nodeId"] = "NODE_001";
      request["lastKnownVersion"] = localState.version;
      
      char buffer[128];
      serializeJson(request, buffer);
      
      mqttClient.publish("sync/request", buffer, false, 1);
      
      // Définir un timeout: si pas de réponse en 10s, retry
      // (implémentation avec état machine)
    }
    
    // Traitement des mises à jour reçues
    void handleRemoteStateUpdate(const char* payload) {
      StaticJsonDocument<256> msg;
      DeserializationError error = deserializeJson(msg, payload);
      
      if (error) {
        Serial.println("JSON parse error");
        return;
      }
      
      uint32_t remoteVersion = msg["version"];
      uint32_t remoteLamportClock = msg["lamportClock"];
      const char* source = msg["source"];
      JsonVariant value = msg["value"];
      
      // Mise à jour Lamport clock
      updateLamportClockFromMessage(remoteLamportClock);
      
      // Détection de conflit: résolution par priorité (version + source ID)
      if (remoteVersion > localState.version) {
        // Update remote est plus récent
        String key = msg["key"];
        localState.payload[key] = value;
        localState.version = remoteVersion;
        
        Serial.print("State updated from ");
        Serial.print(source);
        Serial.print(", new version: ");
        Serial.println(remoteVersion);
      } else if (remoteVersion == localState.version) {
        // Conflit! Résoudre par ordre alphabétique du source ID
        String remoteSourceId = source;
        if (remoteSourceId < "NODE_001") { // Exemple
          // Accepter la version distante
          String key = msg["key"];
          localState.payload[key] = value;
        }
        // Sinon ignorer (notre version gagne)
      }
      // Si remoteVersion < localState.version, ignorer (on est plus à jour)
    }
    
    // Snapshot pour persistance locale (SPIFFS)
    void persistStateSnapshot() {
      // Écrire l'état actuel sur SPIFFS/LittleFS
      // Utile pour reprendre après reboot sans attendre le serveur
      // Code d'écriture spécifique à l'architecture
    }
    
    // Test de connectivité avec retry stratégique
    bool ensureConnected() {
      if (mqttClient.connected()) {
        return true;
      }
      
      // Backoff exponentiel
      static unsigned long lastAttempt = 0;
      static int retryCount = 0;
      
      unsigned long now = millis();
      unsigned long waitTime = (1000 * (1 << min(retryCount, 5))); // max 32s
      
      if (now - lastAttempt > waitTime) {
        if (mqttClient.connect("NODE_001")) {
          retryCount = 0;
          subscribeToRelevantTopics();
          // Demander reconciliation
          reconcileStateOnReconnection();
          return true;
        } else {
          retryCount++;
          lastAttempt = now;
          return false;
        }
      }
      return false;
    }
    
    void subscribeToRelevantTopics() {
      mqttClient.subscribe("state/updates");
      mqttClient.subscribe("state/sync/response");
      mqttClient.subscribe("cmd/NODE_001/#");
    }
};

// Utilisation
DistributedStateManager stateManager;

void onMqttMessage(String &topic, String &payload) {
  if (topic == "state/updates") {
    stateManager.handleRemoteStateUpdate(payload.c_str());
  }
}

void setup() {
  Serial.begin(115200);
  // Initialisation...
}

void loop() {
  // Assurer connectivité
  if (!stateManager.ensureConnected()) {
    delay(1000);
    return;
  }
  
  // Exemple: mise à jour d'état
  static unsigned long lastUpdate = 0;
  if (millis() - lastUpdate > 5000) {
    float temperature = readTemperature();
    stateManager.updateLocalState("temperature", temperature);
    lastUpdate = millis();
  }
}
Aspect MQTT QoS 0 MQTT QoS 1 MQTT QoS 2 CoAP
Overhead réseau Minimal (~1 message) Moyen (~3 messages) Haut (~5+ messages) Très bas (< 100 bytes)
Garantie livraison Aucune Au moins une fois Exactement une fois Configurable
Consommation RAM Très basse Basse-moyenne Moyenne Basse
Fiabilité Basse (50-80%) Haute (95%+) Très haute (99%+) Moyenne
Cas d'usage idéal Logs non-critiques Capteurs importants Commandes critiques Réseaux 6LoWPAN

Astuce Performance: Implémentez une stratégie de batching : au lieu de publier chaque mesure immédiatement, accumulez 10 lectures et publiez un array JSON compressé. Cela réduit le trafic réseau de 90% tout en gardant la réactivité acceptable (buffering de 50ms).

Attention Critique: La dérive d'horloge entre nœuds causes des bugs subtils (timestamps désynchronisés, décisions causales invalides). Utilisez toujours les Lamport clocks pour la causalité, pas les timestamps absolus. Si vous synchronisez les horloges matérielles (NTP), acceptez une marge d'erreur de ±5 secondes minimum.


Debugging et Monitoring en Environnement Distribué

Définition: Le debugging distribué consiste à diagnostiquer des problèmes dans un écosystème IoT multi-nœuds où chaque appareillage exécute du code asynchrone, avec des états interconnectés et des défaillances réseau intermittentes, en utilisant des techniques de log distribué, traces causales et monitoring en temps réel.

Explication détaillée: Déboguer un système IoT distribué est exponentiellement plus complexe qu'un programme simple. Vous ne pouvez pas utiliser un débogueur pas-à-pas classique avec des points d'arrêt car cela gèle la communication réseau et change complètement le timing d'exécution (Heisenbug). Les défaillances sont intermittentes et aléatoires : un comportement apparaît uniquement sous charge ou après 36 heures. Les pires bugs combinaent timing, mémoire et réseau : un débordement de buffer temporaire qui provoque une reconnexion, qui déclenche une fuite mémoire lente, qui après 10 heures cause un redémarrage. Les solutions professionnelles utilisent des logs structurés avec contexte (node ID, timestamp, correlation ID), des traces distribuées (distributed tracing), et des métriques en temps réel. Les tools critiques : serial monitor avancé, backends de log centralisés, outils de profiling RAM/CPU.

#include <ArduinoJson.h>
#include <WiFi.h>

// Système de logging distribué avancé
class DistributedLogger {
  public:
    enum LogLevel {
      DEBUG = 0,
      INFO = 1,
      WARN = 2,
      ERROR = 3,
      CRITICAL = 4
    };
    
  private:
    struct LogEntry {
      uint32_t timestamp;
      uint32_t correlationId;
      uint32_t nodeId;
      LogLevel level;
      const char* category;
      const char* message;
      int value1;
      int value2;
    };
    
    static const int BUFFER_SIZE = 100;
    LogEntry buffer[BUFFER_SIZE];
    int bufferIndex = 0;
    uint32_t globalCorrelationId = 0;
    uint32_t nodeId = 1;
    
    // Serveur de logs centralisé
    const char* logServerAddress = "logs.example.com";
    int logServerPort = 9999;
    WiFiClient logClient;
    
  public:
    void setNodeId(uint32_t id) {
      nodeId = id;
    }
    
    uint32_t generateCorrelationId() {
      return ++globalCorrelationId;
    }
    
    // Logging structuré
    void log(LogLevel level, const char* category, const char* message,
             int value1 = -1, int value2 = -1, uint32_t correlationId = 0) {
      
      if (bufferIndex >= BUFFER_SIZE) {
        // Flush si buffer plein
        flushLogs();
        bufferIndex = 0;
      }
      
      LogEntry& entry = buffer[bufferIndex++];
      entry.timestamp = millis();
      entry.correlationId = (correlationId == 0) ? globalCorrelationId : correlationId;
      entry.nodeId = nodeId;
      entry.level = level;
      entry.category = category;
      entry.message = message;
      entry.value1 = value1;
      entry.value2 = value2;
      
      // Aussi afficher en local
      printToSerial(entry);
      
      // Flush immédiat pour logs CRITICAL
      if (level == CRITICAL) {
        flushLogs();
        bufferIndex = 0;
      }
    }
    
    void printToSerial(const LogEntry& entry) {
      // Format: [LEVEL] [time] [node:corr] [cat] msg (v1, v2)
      const char* levelStr[] = {"DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"};
      
      Serial.print(F("["));
      Serial.print(levelStr[entry.level]);
      Serial.print(F("] "));
      Serial.print(entry.timestamp);
      Serial.print(F(" [N"));
      Serial.print(entry.nodeId);
      Serial.print(F(":"));
      Serial.print(entry.correlationId);
      Serial.print(F("] "));
      Serial.print(entry.category);
      Serial.print(F(" "));
      Serial.print(entry.message);
      
      if (entry.value1 >= 0) {
        Serial.print(F(" ("));
        Serial.print(entry.value1);
        if (entry.value2 >= 0) {
          Serial.print(F(", ");
          Serial.print(entry.value2);
        }
        Serial.print(F(")"));
      }
      Serial.println();
    }
Accédez à des centaines d'examens QCM — Découvrir les offres Premium