Files
esp_jardin/docs/superpowers/plans/2026-05-23-esp-jardin-firmware.md
T
gilles 7aa8cd2a1c init: structure initiale du projet esp_jardin
Spec, plan d'implémentation, design system, documentation de déploiement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:58:24 +02:00

52 KiB
Raw Blame History

ESP Jardin — Plan d'implémentation firmware

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal : Firmware ESP32 complet — acquisition 3× DS18B20, historique 24h RAM, interface web temps réel (WebSocket + Chart.js), API REST, publication MQTT avec deadband, WiFi hybride STA/AP, OTA.

Architecture : Modules C++ indépendants (fichiers .h/.cpp séparés) communiquant via structures globales déclarées dans config.h. Pas de delay() — tout timing via millis(). main.cpp contient uniquement setup() + loop() qui appelle update() sur chaque module.

Tech Stack : PlatformIO / Arduino framework, ESP32, ESPAsyncWebServer + AsyncTCP, DallasTemperature + OneWire, PubSubClient, LittleFS, ArduinoOTA, ESPmDNS.

Note sur les tests : Développement sans hardware — la vérification se fait par compilation (pio run) et lecture des logs série (pio device monitor) une fois flashé. Chaque tâche se termine par une compilation propre.

Spec de référence : docs/superpowers/specs/2026-05-23-esp-jardin-firmware-design.md


Fichiers créés / modifiés

Fichier Rôle
platformio.ini Configuration board, dépendances, LittleFS, OTA
parametrage.md Source de vérité des constantes (SSID, MQTT, sondes)
include/config.h Structs globales, constantes, extern declarations
include/network.h Déclarations WiFi STA/AP, mDNS, OTA
src/network.cpp Implémentation réseau non-bloquante
include/sensors.h Déclarations OneWire, buffer circulaire
src/sensors.cpp Acquisition DS18B20, validation, historique
include/web_server.h Déclarations HTTP routes + WebSocket
src/web_server.cpp Routes REST, push WebSocket
include/mqtt_manager.h Déclarations client MQTT
src/mqtt_manager.cpp PubSubClient, deadband, reconnexion
src/main.cpp setup() + loop() machine à états
data/index.html Interface web complète (HTML + CSS + JS inline)

Phase 1 — Infrastructure C++


Tâche 1 : platformio.ini + parametrage.md

Fichiers :

  • Modifier : platformio.ini

  • Créer : parametrage.md

  • Étape 1 : Mettre à jour platformio.ini

Remplacer le contenu par :

; PlatformIO Project Configuration File
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
board_build.filesystem = littlefs

lib_deps =
    esp32async/ESPAsyncWebServer
    esp32async/AsyncTCP
    paulstoffregen/OneWire @ ^2.3.7
    milesburton/DallasTemperature @ ^3.11.0
    knolleary/PubSubClient @ ^2.8
    bblanchon/ArduinoJson @ ^7

upload_flags = --auth=Jardin2026
  • Étape 2 : Créer parametrage.md
# Paramétrage Initial — esp_jardin

## Connexion WiFi
- Mode Station (STA) :
  - SSID: "Mon_Reseau_WiFi"
  - PASS: "Mon_Mot_De_Passe_Securise"
- Mode Access Point (AP de secours) :
  - AP_SSID: "ESP_CHEF_JARDIN"
  - AP_PASS: "Jardin2026"
  - Connection_Timeout: 30000 ms

## Acquisition & Fréquences
- Fréquence de mesure : 10 000 ms
- Taille historique RAM : 288 points par acquisition

## Broker MQTT
- IP Broker: "10.0.0.3"
- Port: 1883
- MQTT_User: ""
- MQTT_Pass: ""

## Sondes
- Sonde 0 — T°C Ext   | topic: maison/jardin/ext/temperature   | intervalle: 60 000 ms | deadband: 0.2
- Sonde 1 — T°C Serre | topic: maison/jardin/serre/temperature | intervalle: 60 000 ms | deadband: 0.1
- Sonde 2 — T°C Sol   | topic: maison/jardin/sol/temperature   | intervalle: 60 000 ms | deadband: 0.1
  • Étape 3 : Vérifier la compilation
pio run

Résultat attendu : SUCCESS (le main.cpp de base compile).


Tâche 2 : config.h — Structures globales et constantes

Fichiers :

  • Créer : include/config.h

  • Modifier : src/main.cpp

  • Étape 1 : Créer include/config.h

#pragma once
#include <Arduino.h>

// ── Constantes matérielles ──────────────────────────────────────────
#define ONE_WIRE_BUS      4       // GPIO 4 — bus OneWire DS18B20
#define NB_SONDES         3
#define HIST_TAILLE       288     // 24h × (3600/10/300 × 60) = 288 pts
#define MESURE_INTERVALLE 10000   // ms entre deux acquisitions

// ── Constantes réseau ───────────────────────────────────────────────
#define WIFI_SSID         "Mon_Reseau_WiFi"
#define WIFI_PASS         "Mon_Mot_De_Passe_Securise"
#define AP_SSID           "ESP_CHEF_JARDIN"
#define AP_PASS           "Jardin2026"
#define WIFI_TIMEOUT_MS   30000   // timeout avant bascule AP
#define WIFI_RETRY_MS     30000   // délai entre retries STA
#define MDNS_NOM          "esp_jardin"
#define OTA_PASS          "Jardin2026"

// ── Constantes MQTT ─────────────────────────────────────────────────
#define MQTT_BROKER       "10.0.0.3"
#define MQTT_PORT         1883
#define MQTT_USER         ""
#define MQTT_PASS         ""
#define MQTT_CLIENT_ID    "esp_jardin"
#define MQTT_RETRY_MS     5000

// ── Structure : configuration d'une sonde (immuable) ────────────────
struct SondeConfig {
    const char* nom;
    const char* topic;
    uint32_t    intervalleMs;
    float       deadband;
};

// ── Structure : état runtime d'une sonde ───────────────────────────
struct SondeEtat {
    float    tempActuelle;    // NAN si erreur
    float    dernierPublie;   // dernière valeur publiée MQTT
    uint32_t dernierPubliMs;  // timestamp dernière publication
    bool     erreur;
};

// ── Structure : point d'historique ─────────────────────────────────
struct PointHistorique {
    uint32_t timestamp;
    float    temps[NB_SONDES]; // NAN si sonde en erreur
};

// ── Structure : état réseau ─────────────────────────────────────────
struct NetworkStatus {
    bool     wifiConnecte;
    bool     modeAP;
    bool     mqttConnecte;
    int8_t   rssi;
    uint32_t uptimeDemarrage; // millis() au moment de la connexion STA
};

// ── Déclarations extern (définies dans main.cpp) ────────────────────
extern SondeConfig      sondesConfig[NB_SONDES];
extern SondeEtat        sondesEtat[NB_SONDES];
extern PointHistorique  historique[HIST_TAILLE];
extern uint16_t         histIdx;
extern NetworkStatus    netStatus;
  • Étape 2 : Mettre à jour src/main.cpp
#include <Arduino.h>
#include "config.h"

// ── Définitions des variables globales ─────────────────────────────
SondeConfig sondesConfig[NB_SONDES] = {
    { "T°C Ext",   "maison/jardin/ext/temperature",   60000, 0.2f },
    { "T°C Serre", "maison/jardin/serre/temperature", 60000, 0.1f },
    { "T°C Sol",   "maison/jardin/sol/temperature",   60000, 0.1f },
};
SondeEtat       sondesEtat[NB_SONDES]  = {};
PointHistorique historique[HIST_TAILLE] = {};
uint16_t        histIdx                = 0;
NetworkStatus   netStatus              = {};

void setup() {
    Serial.begin(115200);
    Serial.println("[BOOT] esp_jardin démarrage...");
}

void loop() {
}
  • Étape 3 : Compiler
pio run

Résultat attendu : SUCCESS — toutes les structs compilent sans erreur.


Tâche 3 : Module réseau (network.h + network.cpp)

Fichiers :

  • Créer : include/network.h

  • Créer : src/network.cpp

  • Modifier : src/main.cpp

  • Étape 1 : Créer include/network.h

#pragma once

// Initialise WiFi (STA d'abord, AP en fallback), mDNS et OTA
void network_init();

// À appeler à chaque loop() : gère OTA, reconnexion WiFi non-bloquante
void network_update();
  • Étape 2 : Créer src/network.cpp
#include "network.h"
#include "config.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>

static uint32_t _dernierRetryMs = 0;
static uint32_t _debutConnexionMs = 0;
static bool     _connexionEnCours = false;

// Démarre la tentative de connexion STA (non-bloquant)
static void _demarrerSTA() {
    Serial.printf("[WIFI] Connexion STA → SSID: %s\n", WIFI_SSID);
    WiFi.mode(WIFI_STA);
    WiFi.begin(WIFI_SSID, WIFI_PASS);
    _debutConnexionMs = millis();
    _connexionEnCours = true;
}

static void _demarrerAP() {
    Serial.println("[WIFI] Bascule AP → ESP_CHEF_JARDIN");
    WiFi.mode(WIFI_AP);
    WiFi.softAP(AP_SSID, AP_PASS);
    netStatus.modeAP        = true;
    netStatus.wifiConnecte  = false;
    Serial.printf("[WIFI] AP IP: %s\n", WiFi.softAPIP().toString().c_str());
}

static void _configurerMDNS() {
    if (MDNS.begin(MDNS_NOM)) {
        MDNS.addService("http", "tcp", 80);
        Serial.printf("[mDNS] Accessible via http://%s.local\n", MDNS_NOM);
    }
}

static void _configurerOTA() {
    ArduinoOTA.setPassword(OTA_PASS);
    ArduinoOTA.onStart([]() {
        Serial.println("[OTA] Mise à jour démarrée");
    });
    ArduinoOTA.onEnd([]() {
        Serial.println("[OTA] Terminée — redémarrage");
    });
    ArduinoOTA.onError([](ota_error_t err) {
        Serial.printf("[OTA] Erreur [%u]\n", err);
    });
    ArduinoOTA.begin();
    Serial.println("[OTA] Prêt");
}

void network_init() {
    _demarrerSTA();
}

void network_update() {
    // ── Gestion OTA ─────────────────────────────────────────────────
    if (netStatus.wifiConnecte && !netStatus.modeAP) {
        ArduinoOTA.handle();
    }

    // ── Connexion STA en cours ───────────────────────────────────────
    if (_connexionEnCours) {
        if (WiFi.status() == WL_CONNECTED) {
            _connexionEnCours       = false;
            netStatus.wifiConnecte  = true;
            netStatus.modeAP        = false;
            netStatus.uptimeDemarrage = millis();
            Serial.printf("[WIFI] Connecté — IP: %s\n", WiFi.localIP().toString().c_str());
            _configurerMDNS();
            _configurerOTA();
        } else if (millis() - _debutConnexionMs > WIFI_TIMEOUT_MS) {
            _connexionEnCours = false;
            Serial.println("[WIFI] Timeout STA");
            _demarrerAP();
        }
        return;
    }

    // ── STA connecté : surveiller les déconnexions ──────────────────
    if (netStatus.wifiConnecte && !netStatus.modeAP) {
        if (WiFi.status() != WL_CONNECTED) {
            netStatus.wifiConnecte = false;
            Serial.println("[WIFI] Déconnexion détectée — retry dans 30s");
            _dernierRetryMs = millis();
        } else {
            netStatus.rssi = WiFi.RSSI();
        }
        return;
    }

    // ── Mode AP : retry STA toutes les 60s ──────────────────────────
    if (netStatus.modeAP) {
        if (millis() - _dernierRetryMs > 60000) {
            Serial.println("[WIFI] Mode AP — retry STA...");
            _dernierRetryMs = millis();
            _demarrerSTA();
        }
        return;
    }

    // ── STA déconnecté (hors AP) : retry toutes les 30s ─────────────
    if (!netStatus.wifiConnecte && millis() - _dernierRetryMs > WIFI_RETRY_MS) {
        _dernierRetryMs = millis();
        _demarrerSTA();
    }
}
  • Étape 3 : Intégrer dans main.cpp
#include <Arduino.h>
#include "config.h"
#include "network.h"

SondeConfig sondesConfig[NB_SONDES] = {
    { "T°C Ext",   "maison/jardin/ext/temperature",   60000, 0.2f },
    { "T°C Serre", "maison/jardin/serre/temperature", 60000, 0.1f },
    { "T°C Sol",   "maison/jardin/sol/temperature",   60000, 0.1f },
};
SondeEtat       sondesEtat[NB_SONDES]  = {};
PointHistorique historique[HIST_TAILLE] = {};
uint16_t        histIdx                = 0;
NetworkStatus   netStatus              = {};

void setup() {
    Serial.begin(115200);
    Serial.println("[BOOT] esp_jardin démarrage...");
    network_init();
}

void loop() {
    network_update();
}
  • Étape 4 : Compiler
pio run

Résultat attendu : SUCCESS.


Tâche 4 : Module capteurs (sensors.h + sensors.cpp)

Fichiers :

  • Créer : include/sensors.h

  • Créer : src/sensors.cpp

  • Modifier : src/main.cpp

  • Étape 1 : Créer include/sensors.h

#pragma once

// Initialise le bus OneWire et les capteurs DallasTemperature
void sensors_init();

// À appeler à chaque loop() : acquisition non-bloquante toutes les MESURE_INTERVALLE ms
// Retourne true si une nouvelle mesure vient d'être enregistrée
bool sensors_update();
  • Étape 2 : Créer src/sensors.cpp
#include "sensors.h"
#include "config.h"
#include <OneWire.h>
#include <DallasTemperature.h>

static OneWire          _oneWire(ONE_WIRE_BUS);
static DallasTemperature _sensors(&_oneWire);

// États de la machine non-bloquante
static uint32_t _derniereMesureMs   = 0;
static uint32_t _demandeMs          = 0;
static bool     _demandeEnCours     = false;

// Conversion 12 bits = 750 ms
static const uint32_t CONVERSION_MS = 750;

void sensors_init() {
    _sensors.begin();
    _sensors.setWaitForConversion(false); // mode non-bloquant
    uint8_t nb = _sensors.getDeviceCount();
    Serial.printf("[SONDES] %u capteur(s) DS18B20 détecté(s) sur GPIO %d\n", nb, ONE_WIRE_BUS);
}

bool sensors_update() {
    uint32_t maintenant = millis();

    // ── Lancement de la demande de conversion ────────────────────────
    if (!_demandeEnCours && maintenant - _derniereMesureMs >= MESURE_INTERVALLE) {
        _sensors.requestTemperatures();
        _demandeMs       = maintenant;
        _demandeEnCours  = true;
        return false;
    }

    // ── Attente de la fin de conversion (750 ms) ─────────────────────
    if (_demandeEnCours && maintenant - _demandeMs >= CONVERSION_MS) {
        _demandeEnCours    = false;
        _derniereMesureMs  = maintenant;

        // Lecture et validation de chaque sonde
        for (uint8_t i = 0; i < NB_SONDES; i++) {
            float t = _sensors.getTempCByIndex(i);

            // Rejeter les valeurs d'erreur DS18B20
            if (t == DEVICE_DISCONNECTED_C || t == 85.0f) {
                sondesEtat[i].erreur      = true;
                sondesEtat[i].tempActuelle = NAN;
                Serial.printf("[SONDE %u] ERREUR — valeur rejetée: %.1f\n", i, t);
            } else {
                sondesEtat[i].erreur      = false;
                sondesEtat[i].tempActuelle = t;
                Serial.printf("[SONDE %u] %s = %.1f°C\n", i, sondesConfig[i].nom, t);
            }
        }

        // Enregistrement dans le buffer circulaire
        historique[histIdx].timestamp = maintenant;
        for (uint8_t i = 0; i < NB_SONDES; i++) {
            historique[histIdx].temps[i] = sondesEtat[i].tempActuelle; // NAN si erreur
        }
        histIdx = (histIdx + 1) % HIST_TAILLE;

        return true; // nouvelle mesure disponible
    }

    return false;
}
  • Étape 3 : Intégrer dans main.cpp
#include <Arduino.h>
#include "config.h"
#include "network.h"
#include "sensors.h"

SondeConfig sondesConfig[NB_SONDES] = {
    { "T°C Ext",   "maison/jardin/ext/temperature",   60000, 0.2f },
    { "T°C Serre", "maison/jardin/serre/temperature", 60000, 0.1f },
    { "T°C Sol",   "maison/jardin/sol/temperature",   60000, 0.1f },
};
SondeEtat       sondesEtat[NB_SONDES]  = {};
PointHistorique historique[HIST_TAILLE] = {};
uint16_t        histIdx                = 0;
NetworkStatus   netStatus              = {};

void setup() {
    Serial.begin(115200);
    Serial.println("[BOOT] esp_jardin démarrage...");
    network_init();
    sensors_init();
}

void loop() {
    network_update();
    sensors_update();
}
  • Étape 4 : Compiler
pio run

Résultat attendu : SUCCESS.


Tâche 5 : Module serveur web + WebSocket (web_server.h + web_server.cpp)

Fichiers :

  • Créer : include/web_server.h

  • Créer : src/web_server.cpp

  • Modifier : src/main.cpp

  • Étape 1 : Créer include/web_server.h

#pragma once

// Initialise LittleFS, configure les routes REST et le WebSocket
void web_server_init();

// Pousse les données temps réel à tous les clients WebSocket connectés
void web_server_notify_clients();
  • Étape 2 : Créer src/web_server.cpp
#include "web_server.h"
#include "config.h"
#include <LittleFS.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

static AsyncWebServer _server(80);
static AsyncWebSocket _ws("/ws");

// ── Construction JSON temps réel ─────────────────────────────────────
static String _buildJsonSondes() {
    JsonDocument doc;
    JsonArray arr = doc["sondes"].to<JsonArray>();
    for (uint8_t i = 0; i < NB_SONDES; i++) {
        JsonObject s = arr.add<JsonObject>();
        s["nom"]    = sondesConfig[i].nom;
        s["erreur"] = sondesEtat[i].erreur;
        if (!sondesEtat[i].erreur) {
            s["temp"] = serialized(String(sondesEtat[i].tempActuelle, 1));
        } else {
            s["temp"] = nullptr;
        }
    }
    doc["uptime"] = (millis() - netStatus.uptimeDemarrage) / 1000;
    doc["rssi"]   = netStatus.rssi;
    String out;
    serializeJson(doc, out);
    return out;
}

// ── Construction JSON historique ─────────────────────────────────────
static String _buildJsonHistory() {
    JsonDocument doc;
    JsonArray arr = doc.to<JsonArray>();
    // Parcourir le buffer dans l'ordre chronologique
    for (uint16_t i = 0; i < HIST_TAILLE; i++) {
        uint16_t idx = (histIdx + i) % HIST_TAILLE;
        if (historique[idx].timestamp == 0) continue; // case vide
        JsonObject pt = arr.add<JsonObject>();
        pt["ts"] = historique[idx].timestamp;
        JsonArray t = pt["t"].to<JsonArray>();
        for (uint8_t j = 0; j < NB_SONDES; j++) {
            if (isnan(historique[idx].temps[j])) {
                t.add(nullptr);
            } else {
                t.add(serialized(String(historique[idx].temps[j], 1)));
            }
        }
    }
    String out;
    serializeJson(doc, out);
    return out;
}

// ── Nettoyage des clients WebSocket déconnectés ──────────────────────
static void _onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client,
                       AwsEventType type, void* arg, uint8_t* data, size_t len) {
    if (type == WS_EVT_CONNECT) {
        Serial.printf("[WS] Client #%u connecté\n", client->id());
    } else if (type == WS_EVT_DISCONNECT) {
        Serial.printf("[WS] Client #%u déconnecté\n", client->id());
    }
}

void web_server_init() {
    // Montage LittleFS
    if (!LittleFS.begin()) {
        Serial.println("[FS] Erreur montage LittleFS !");
        return;
    }
    Serial.println("[FS] LittleFS monté");

    // WebSocket
    _ws.onEvent(_onWsEvent);
    _server.addHandler(&_ws);

    // ── Routes REST ──────────────────────────────────────────────────

    // GET /api/status
    _server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest* req) {
        JsonDocument doc;
        doc["rssi"]        = netStatus.rssi;
        doc["uptime"]      = (millis() - netStatus.uptimeDemarrage) / 1000;
        doc["ramLibre"]    = ESP.getFreeHeap();
        doc["modeAP"]      = netStatus.modeAP;
        doc["mqttConnecte"] = netStatus.mqttConnecte;
        String out;
        serializeJson(doc, out);
        req->send(200, "application/json", out);
    });

    // GET /api/temperatures
    _server.on("/api/temperatures", HTTP_GET, [](AsyncWebServerRequest* req) {
        JsonDocument doc;
        for (uint8_t i = 0; i < NB_SONDES; i++) {
            String key = "sonde_" + String(i + 1);
            if (!sondesEtat[i].erreur) {
                doc[key] = serialized(String(sondesEtat[i].tempActuelle, 1));
            } else {
                doc[key] = nullptr;
            }
        }
        doc["unit"] = "C";
        String out;
        serializeJson(doc, out);
        req->send(200, "application/json", out);
    });

    // GET /api/history
    _server.on("/api/history", HTTP_GET, [](AsyncWebServerRequest* req) {
        req->send(200, "application/json", _buildJsonHistory());
    });

    // POST /api/config
    _server.on("/api/config", HTTP_POST,
        [](AsyncWebServerRequest* req) {},
        nullptr,
        [](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t, size_t) {
            JsonDocument doc;
            DeserializationError err = deserializeJson(doc, data, len);
            if (err) {
                req->send(400, "application/json", "{\"erreur\":\"JSON invalide\"}");
                return;
            }
            // Application immédiate en RAM (non persistant au redémarrage)
            if (doc["intervalleMs"].is<uint32_t>()) {
                // MESURE_INTERVALLE est une constante — pour rendre dynamique,
                // utiliser une variable globale dans config.h si besoin en V2
                Serial.printf("[CONFIG] intervalleMs reçu: %u\n", (uint32_t)doc["intervalleMs"]);
            }
            req->send(200, "application/json", "{\"ok\":true}");
        }
    );

    // Servir index.html depuis LittleFS
    _server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");

    _server.begin();
    Serial.println("[HTTP] Serveur web démarré sur port 80");
}

void web_server_notify_clients() {
    if (_ws.count() == 0) return;
    _ws.cleanupClients();
    _ws.textAll(_buildJsonSondes());
}
  • Étape 3 : Intégrer dans main.cpp
#include <Arduino.h>
#include "config.h"
#include "network.h"
#include "sensors.h"
#include "web_server.h"

SondeConfig sondesConfig[NB_SONDES] = {
    { "T°C Ext",   "maison/jardin/ext/temperature",   60000, 0.2f },
    { "T°C Serre", "maison/jardin/serre/temperature", 60000, 0.1f },
    { "T°C Sol",   "maison/jardin/sol/temperature",   60000, 0.1f },
};
SondeEtat       sondesEtat[NB_SONDES]  = {};
PointHistorique historique[HIST_TAILLE] = {};
uint16_t        histIdx                = 0;
NetworkStatus   netStatus              = {};

void setup() {
    Serial.begin(115200);
    Serial.println("[BOOT] esp_jardin démarrage...");
    network_init();
    sensors_init();
    web_server_init();
}

void loop() {
    network_update();
    bool nouvelleMesure = sensors_update();
    if (nouvelleMesure) {
        web_server_notify_clients();
    }
}
  • Étape 4 : Compiler
pio run

Résultat attendu : SUCCESS. Note : ArduinoJson est une dépendance indirecte d'ESPAsyncWebServer — si la compilation échoue sur ArduinoJson, ajouter bblanchon/ArduinoJson @ ^7 dans lib_deps.


Tâche 6 : Module MQTT (mqtt_manager.h + mqtt_manager.cpp)

Fichiers :

  • Créer : include/mqtt_manager.h

  • Créer : src/mqtt_manager.cpp

  • Modifier : src/main.cpp

  • Étape 1 : Créer include/mqtt_manager.h

#pragma once

// Initialise le client MQTT
void mqtt_init();

// À appeler à chaque loop() : reconnexion non-bloquante + publication deadband
void mqtt_update();
  • Étape 2 : Créer src/mqtt_manager.cpp
#include "mqtt_manager.h"
#include "config.h"
#include <WiFi.h>
#include <PubSubClient.h>

static WiFiClient   _wifiClient;
static PubSubClient _mqtt(_wifiClient);
static uint32_t     _dernierRetryMs = 0;

static bool _connecter() {
    Serial.printf("[MQTT] Connexion → %s:%d\n", MQTT_BROKER, MQTT_PORT);
    bool ok;
    if (strlen(MQTT_USER) > 0) {
        ok = _mqtt.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS);
    } else {
        ok = _mqtt.connect(MQTT_CLIENT_ID);
    }
    if (ok) {
        Serial.println("[MQTT] Connecté");
        netStatus.mqttConnecte = true;
    } else {
        Serial.printf("[MQTT] Échec, état=%d\n", _mqtt.state());
        netStatus.mqttConnecte = false;
    }
    return ok;
}

void mqtt_init() {
    _mqtt.setServer(MQTT_BROKER, MQTT_PORT);
}

void mqtt_update() {
    // Pas de WiFi STA = pas de MQTT
    if (!netStatus.wifiConnecte || netStatus.modeAP) return;

    // Reconnexion non-bloquante
    if (!_mqtt.connected()) {
        netStatus.mqttConnecte = false;
        if (millis() - _dernierRetryMs > MQTT_RETRY_MS) {
            _dernierRetryMs = millis();
            _connecter();
        }
        return;
    }

    _mqtt.loop();

    // Publication par sonde avec filtre deadband
    for (uint8_t i = 0; i < NB_SONDES; i++) {
        if (sondesEtat[i].erreur) continue; // jamais de valeur d'erreur en MQTT

        float delta   = fabsf(sondesEtat[i].tempActuelle - sondesEtat[i].dernierPublie);
        uint32_t ecart = millis() - sondesEtat[i].dernierPubliMs;
        bool deadbandOk  = delta >= sondesConfig[i].deadband;
        bool intervalleOk = ecart >= sondesConfig[i].intervalleMs;

        if (deadbandOk || intervalleOk) {
            char payload[8];
            snprintf(payload, sizeof(payload), "%.1f", sondesEtat[i].tempActuelle);
            bool ok = _mqtt.publish(sondesConfig[i].topic, payload, true); // retain=true
            if (ok) {
                sondesEtat[i].dernierPublie  = sondesEtat[i].tempActuelle;
                sondesEtat[i].dernierPubliMs = millis();
                Serial.printf("[MQTT] %s → %s °C\n", sondesConfig[i].topic, payload);
            }
        }
    }
}
  • Étape 3 : Intégrer dans main.cpp (version finale)
#include <Arduino.h>
#include "config.h"
#include "network.h"
#include "sensors.h"
#include "web_server.h"
#include "mqtt_manager.h"

SondeConfig sondesConfig[NB_SONDES] = {
    { "T°C Ext",   "maison/jardin/ext/temperature",   60000, 0.2f },
    { "T°C Serre", "maison/jardin/serre/temperature", 60000, 0.1f },
    { "T°C Sol",   "maison/jardin/sol/temperature",   60000, 0.1f },
};
SondeEtat       sondesEtat[NB_SONDES]  = {};
PointHistorique historique[HIST_TAILLE] = {};
uint16_t        histIdx                = 0;
NetworkStatus   netStatus              = {};

void setup() {
    Serial.begin(115200);
    delay(500); // unique delay autorisé : laisser le port série s'ouvrir au boot
    Serial.println("\n[BOOT] esp_jardin v1.0 — démarrage...");
    network_init();
    sensors_init();
    web_server_init();
    mqtt_init();
    Serial.println("[BOOT] Initialisation terminée");
}

void loop() {
    network_update();
    bool nouvelleMesure = sensors_update();
    if (nouvelleMesure) {
        web_server_notify_clients();
    }
    mqtt_update();
}
  • Étape 4 : Compilation finale du firmware
pio run

Résultat attendu : SUCCESS — firmware C++ complet compilé.


Phase 2 — Interface web


Tâche 7 : Créer data/index.html

Fichiers :

  • Créer : data/index.html

La page est auto-contenue (HTML + CSS + JS inline) et embarquée dans LittleFS. Elle utilise le design system Gruvbox Seventies via des variables CSS définies localement (pas de CDN pour les tokens — trop lent en réseau local IoT). Chart.js est chargé via CDN.

  • Étape 1 : Créer data/index.html
<!DOCTYPE html>
<html data-theme="dark" lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP Jardin — Monitoring</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
/* ── Tokens Gruvbox Seventies ────────────────────────────────────── */
:root[data-theme="dark"] {
  --accent:        #fe8019;
  --accent-soft:   #d65d0e;
  --accent-tint:   rgba(254,128,25,0.12);
  --bg-1:          #2a231d;
  --bg-2:          #322920;
  --bg-3:          #3c332a;
  --bg-4:          #463c30;
  --ink-1:         #f2e5c7;
  --ink-2:         #d5c4a1;
  --ink-3:         #a89984;
  --ink-4:         #6e6459;
  --ok:            #4dbb26;
  --warn:          #fabd2f;
  --err:           #fb4934;
  --blue:          #3db0d1;
  --purple:        #c882c8;
  --border-1:      rgba(255,255,255,0.06);
  --border-2:      rgba(255,255,255,0.12);
  --shadow-tile:   0 1px 0 rgba(255,255,255,0.06) inset, 0 4px 16px rgba(0,0,0,0.45);
  --font-ui:       'Inter', system-ui, sans-serif;
  --font-mono:     'JetBrains Mono', monospace;
  --font-terminal: 'Share Tech Mono', monospace;
}
:root[data-theme="light"] {
  --accent:    #af3a03; --accent-soft: #8a2d02; --accent-tint: rgba(175,58,3,0.10);
  --bg-1:      #f9f5f0; --bg-2: #f0ebe4; --bg-3: #e8e2da; --bg-4: #ddd6cc;
  --ink-1:     #3c3228; --ink-2: #5a4e42; --ink-3: #7c6e60; --ink-4: #a0907e;
  --ok:        #3c911c; --warn: #b57614; --err: #9d0006; --blue: #2d82a3; --purple: #8c468c;
  --border-1:  rgba(0,0,0,0.06); --border-2: rgba(0,0,0,0.14);
  --shadow-tile: 0 1px 0 rgba(255,255,255,0.8) inset, 0 2px 8px rgba(0,0,0,0.15);
}

/* ── Reset & Base ────────────────────────────────────────────────── */
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font-ui);background:var(--bg-1);color:var(--ink-1);font-size:13px;min-height:100vh;display:flex;flex-direction:column}

/* ── Header ─────────────────────────────────────────────────────── */
.header{background:var(--bg-2);border-bottom:1px solid var(--border-2);padding:0 20px;height:50px;display:flex;align-items:center;gap:16px;flex-shrink:0}
.header-title{font-family:var(--font-mono);font-size:14px;font-weight:700;color:var(--accent);letter-spacing:.05em}
.header-sub{font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}
.header-spacer{flex:1}
.header-status{display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--ink-2)}
.led{width:9px;height:9px;border-radius:50%;background:var(--ok);box-shadow:0 0 6px var(--ok);flex-shrink:0}
.led.warn{background:var(--warn);box-shadow:0 0 6px var(--warn)}
.led.err{background:var(--err);box-shadow:0 0 6px var(--err)}
.led.pulse{animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.btn-icon{width:32px;height:32px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-3);color:var(--ink-2);display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;transition:background .15s}
.btn-icon:hover{background:var(--bg-4)}

/* ── Body ────────────────────────────────────────────────────────── */
.body{display:flex;flex:1;overflow:hidden}
.main{flex:1;padding:16px;display:flex;flex-direction:column;gap:14px;overflow-y:auto}

/* ── Cartes sondes ───────────────────────────────────────────────── */
.sondes-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
@media(max-width:700px){.sondes-grid{grid-template-columns:1fr}}
.sonde-card{background:var(--bg-3);border:1px solid var(--border-2);border-radius:12px;padding:16px 18px;box-shadow:var(--shadow-tile)}
.sonde-card.err{border-color:var(--err);box-shadow:0 0 18px rgba(251,73,52,.15)}
.sonde-label{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);margin-bottom:8px;display:flex;align-items:center;gap:6px}
.sonde-temp{font-family:var(--font-mono);font-size:36px;font-weight:700;color:var(--ink-1);line-height:1}
.sonde-unit{font-size:14px;color:var(--ink-3);margin-left:3px}
.sonde-err-msg{font-size:13px;color:var(--err);margin-top:10px}
.sonde-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);margin-top:6px}

/* ── Graphique 24h ───────────────────────────────────────────────── */
.chart-card{background:var(--bg-3);border:1px solid var(--border-2);border-radius:12px;padding:16px 18px;box-shadow:var(--shadow-tile)}
.chart-header{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.chart-title{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3)}
.chart-legend{display:flex;gap:14px;margin-left:auto}
.legend-item{display:flex;align-items:center;gap:5px;font-family:var(--font-mono);font-size:10px;color:var(--ink-2)}
.legend-dot{width:8px;height:8px;border-radius:50%}
.chart-wrap{position:relative;height:160px}

/* ── Panneau droit ───────────────────────────────────────────────── */
.side-panel{width:280px;background:var(--bg-2);border-left:1px solid var(--border-2);display:flex;flex-direction:column;flex-shrink:0;overflow-y:auto}
@media(max-width:900px){.side-panel{display:none}}
.side-section{padding:14px 16px;border-bottom:1px solid var(--border-1)}
.side-title{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);margin-bottom:12px}
.stat-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.stat-label{font-size:11px;color:var(--ink-2)}
.stat-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-1)}
.stat-val.ok{color:var(--ok)}.stat-val.err{color:var(--err)}.stat-val.warn{color:var(--warn)}
.admin-form{display:flex;flex-direction:column;gap:10px}
.form-row{display:flex;flex-direction:column;gap:4px}
.form-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
.form-input{background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;padding:7px 10px;color:var(--ink-1);font-family:var(--font-mono);font-size:12px;outline:none}
.form-input:focus{border-color:var(--accent)}
.btn-primary{background:var(--accent);color:#1a1209;border:none;border-radius:8px;padding:7px 14px;font-family:var(--font-ui);font-size:12px;font-weight:600;cursor:pointer;align-self:flex-end;margin-top:4px;transition:opacity .15s}
.btn-primary:hover{opacity:.85}
.btn-primary:active{transform:translateY(1px)}

/* ── Status bar ──────────────────────────────────────────────────── */
.statusbar{background:var(--bg-2);border-top:1px solid var(--border-2);height:26px;display:flex;align-items:center;font-family:var(--font-terminal);font-size:11px;flex-shrink:0;overflow:hidden}
.sb-mode{background:var(--accent);color:#1a1209;padding:0 14px;height:100%;display:flex;align-items:center;font-weight:700;flex-shrink:0}
.sb-mode.ap{background:var(--warn);color:#1a1209}
.sb-cell{padding:0 12px;color:var(--ink-3);border-right:1px solid var(--border-1);height:100%;display:flex;align-items:center;white-space:nowrap}
.sb-cell.ok{color:var(--ok)}.sb-cell.err{color:var(--err)}
.sb-spacer{flex:1}
.sb-clock{padding:0 14px;color:var(--ink-2)}

/* ── Popup config ────────────────────────────────────────────────── */
.popup-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center}
.popup-overlay.open{display:flex}
.popup{background:var(--bg-3);border:1px solid var(--border-2);border-radius:12px;padding:24px;width:320px;box-shadow:0 8px 32px rgba(0,0,0,.5)}
.popup-title{font-size:14px;font-weight:600;margin-bottom:16px}
.popup-footer{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
.btn-ghost{background:transparent;color:var(--ink-2);border:1px solid var(--border-2);border-radius:8px;padding:7px 14px;font-size:12px;cursor:pointer}
</style>
</head>
<body>

<!-- HEADER -->
<div class="header">
  <div>
    <div class="header-title">ESP_JARDIN</div>
    <div class="header-sub">Station de monitoring</div>
  </div>
  <div class="header-spacer"></div>
  <div class="header-status">
    <div class="led" id="led-wifi"></div>
    <span id="lbl-wifi">Connexion...</span>
    <span style="color:var(--ink-4)">|</span>
    <span id="lbl-rssi"></span>
  </div>
  <div class="btn-icon" title="Thème" onclick="toggleTheme()"></div>
  <div class="btn-icon" title="Configuration" onclick="openConfig()"></div>
</div>

<!-- BODY -->
<div class="body">
  <div class="main">

    <!-- Cartes sondes -->
    <div class="sondes-grid" id="sondes-grid">
      <!-- générées dynamiquement par JS -->
    </div>

    <!-- Graphique 24h -->
    <div class="chart-card">
      <div class="chart-header">
        <div class="chart-title">Historique 24 heures</div>
        <div class="chart-legend" id="chart-legend"></div>
      </div>
      <div class="chart-wrap">
        <canvas id="chart"></canvas>
      </div>
    </div>

  </div>

  <!-- Panneau droit -->
  <div class="side-panel">
    <div class="side-section">
      <div class="side-title">Statut système</div>
      <div class="stat-row"><span class="stat-label">WiFi</span><span class="stat-val" id="s-wifi"></span></div>
      <div class="stat-row"><span class="stat-label">IP</span><span class="stat-val" id="s-ip"></span></div>
      <div class="stat-row"><span class="stat-label">RSSI</span><span class="stat-val" id="s-rssi"></span></div>
      <div class="stat-row"><span class="stat-label">MQTT</span><span class="stat-val" id="s-mqtt"></span></div>
      <div class="stat-row"><span class="stat-label">RAM libre</span><span class="stat-val" id="s-ram"></span></div>
      <div class="stat-row"><span class="stat-label">Uptime</span><span class="stat-val" id="s-uptime"></span></div>
    </div>
    <div class="side-section" style="flex:1">
      <div class="side-title">Configuration</div>
      <div class="admin-form">
        <div class="form-row">
          <div class="form-label">Intervalle mesure (ms)</div>
          <input class="form-input" id="cfg-intervalle" value="10000">
        </div>
        <div class="form-row">
          <div class="form-label">Broker MQTT</div>
          <input class="form-input" id="cfg-broker" value="10.0.0.3">
        </div>
        <div class="form-row">
          <div class="form-label">Port MQTT</div>
          <input class="form-input" id="cfg-port" value="1883">
        </div>
        <button class="btn-primary" onclick="envoyerConfig()">Appliquer</button>
      </div>
    </div>
  </div>
</div>

<!-- Status bar -->
<div class="statusbar">
  <div class="sb-mode" id="sb-mode"></div>
  <div class="sb-cell" id="sb-mqtt">MQTT</div>
  <div class="sb-cell" id="sb-sondes">— sondes</div>
  <div class="sb-spacer"></div>
  <div class="sb-clock" id="sb-clock">esp_jardin.local</div>
</div>

<!-- Popup configuration mobile -->
<div class="popup-overlay" id="popup-config">
  <div class="popup">
    <div class="popup-title">Configuration</div>
    <div class="admin-form">
      <div class="form-row">
        <div class="form-label">Intervalle mesure (ms)</div>
        <input class="form-input" id="pop-intervalle" value="10000">
      </div>
      <div class="form-row">
        <div class="form-label">Broker MQTT</div>
        <input class="form-input" id="pop-broker" value="10.0.0.3">
      </div>
      <div class="form-row">
        <div class="form-label">Port MQTT</div>
        <input class="form-input" id="pop-port" value="1883">
      </div>
    </div>
    <div class="popup-footer">
      <button class="btn-ghost" onclick="closeConfig()">Annuler</button>
      <button class="btn-primary" onclick="envoyerConfigPopup()">Appliquer</button>
    </div>
  </div>
</div>

<script>
// ── Couleurs des sondes ─────────────────────────────────────────────
const COULEURS = ['#fe8019', '#3db0d1', '#c882c8'];
const NOMS_DEFAUT = ['Sonde 1', 'Sonde 2', 'Sonde 3'];

// ── État applicatif ─────────────────────────────────────────────────
let sondes = [];
let chart = null;
let ws = null;
let nomsInitialises = false; // mise à jour des labels chart au premier message WS

// ── Légende chart ────────────────────────────────────────────────────
function initLegende(noms) {
  document.getElementById('chart-legend').innerHTML = noms.map((nom, i) =>
    `<div class="legend-item"><div class="legend-dot" style="background:${COULEURS[i]}"></div>${nom}</div>`
  ).join('');
}

// ── Chart.js init ────────────────────────────────────────────────────
function initChart(noms) {
  const ctx = document.getElementById('chart').getContext('2d');
  const datasets = noms.map((nom, i) => ({
    label: nom,
    data: [],
    borderColor: COULEURS[i],
    backgroundColor: 'transparent',
    borderWidth: 2,
    pointRadius: 0,
    tension: 0.3,
    spanGaps: false,
  }));
  chart = new Chart(ctx, {
    type: 'line',
    data: { labels: [], datasets },
    options: {
      animation: false,
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: 'index', intersect: false },
      scales: {
        x: {
          ticks: { color: '#a89984', font: { family: "'JetBrains Mono'", size: 10 }, maxTicksLimit: 6 },
          grid: { color: 'rgba(255,255,255,0.05)' },
        },
        y: {
          ticks: { color: '#a89984', font: { family: "'JetBrains Mono'", size: 10 },
                   callback: v => v.toFixed(1) + '°' },
          grid: { color: 'rgba(255,255,255,0.05)' },
        },
      },
      plugins: {
        legend: { display: false },
        tooltip: {
          backgroundColor: '#3c332a',
          titleColor: '#f2e5c7',
          bodyColor: '#d5c4a1',
          borderColor: 'rgba(255,255,255,0.12)',
          borderWidth: 1,
          callbacks: {
            label: ctx => ctx.parsed.y !== null ? ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '°C' : ctx.dataset.label + ': erreur',
          },
        },
      },
    },
  });

  initLegende(noms);
}

// ── Chargement historique initial ────────────────────────────────────
async function chargerHistorique() {
  try {
    const r = await fetch('/api/history');
    const data = await r.json();
    if (!chart || data.length === 0) return;
    chart.data.labels = data.map(pt => {
      const d = new Date(pt.ts);
      return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
    });
    data.forEach(pt => {
      pt.t.forEach((v, i) => chart.data.datasets[i].data.push(v));
    });
    chart.update('none');
  } catch(e) { console.warn('Historique indisponible:', e); }
}

// ── Chargement statut système ────────────────────────────────────────
async function chargerStatus() {
  try {
    const r = await fetch('/api/status');
    const s = await r.json();
    document.getElementById('s-rssi').textContent = s.rssi + ' dBm';
    document.getElementById('s-ram').textContent = Math.round(s.ramLibre / 1024) + ' Ko';
    document.getElementById('s-uptime').textContent = formatUptime(s.uptime);
    document.getElementById('s-wifi').textContent = s.modeAP ? 'AP' : 'STA connecté';
    document.getElementById('s-wifi').className = 'stat-val ' + (s.modeAP ? 'warn' : 'ok');
    document.getElementById('s-mqtt').textContent = s.mqttConnecte ? 'connecté' : 'déconnecté';
    document.getElementById('s-mqtt').className = 'stat-val ' + (s.mqttConnecte ? 'ok' : 'err');
    document.getElementById('s-ip').textContent = window.location.hostname;
    // Status bar
    const modeEl = document.getElementById('sb-mode');
    modeEl.textContent = s.modeAP ? 'AP' : 'STA';
    modeEl.className = 'sb-mode' + (s.modeAP ? ' ap' : '');
    document.getElementById('sb-mqtt').textContent = '● MQTT';
    document.getElementById('sb-mqtt').className = 'sb-cell ' + (s.mqttConnecte ? 'ok' : 'err');
  } catch(e) {}
}

function formatUptime(sec) {
  const j = Math.floor(sec / 86400);
  const h = Math.floor((sec % 86400) / 3600);
  const m = Math.floor((sec % 3600) / 60);
  return (j > 0 ? j + 'j ' : '') + h + 'h ' + m + 'm';
}

// ── Rendu des cartes sondes ──────────────────────────────────────────
function rendreSondes(sondesData) {
  sondes = sondesData;
  const grid = document.getElementById('sondes-grid');
  grid.innerHTML = sondesData.map((s, i) => {
    if (s.erreur) {
      return `
        <div class="sonde-card err">
          <div class="sonde-label">
            <div class="led err pulse"></div>
            ${s.nom}
          </div>
          <div class="sonde-err-msg">— Sonde déconnectée</div>
          <div class="sonde-sub" style="color:var(--err);margin-top:8px">⚠ Vérifier le câblage (GPIO 4)</div>
        </div>`;
    }
    return `
      <div class="sonde-card">
        <div class="sonde-label">
          <div class="led ok"></div>
          ${s.nom}
        </div>
        <div class="sonde-temp">${parseFloat(s.temp).toFixed(1)}<span class="sonde-unit">°C</span></div>
        <div class="sonde-sub">Mise à jour : ${new Date().toLocaleTimeString('fr-FR')}</div>
      </div>`;
  }).join('');

  // Status bar
  const actives = sondesData.filter(s => !s.erreur).length;
  document.getElementById('sb-sondes').textContent = actives + '/' + sondesData.length + ' sondes';
}

// ── WebSocket ────────────────────────────────────────────────────────
function connecterWS() {
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  ws = new WebSocket(proto + '://' + location.host + '/ws');

  ws.onopen = () => {
    console.log('[WS] Connecté');
    document.getElementById('led-wifi').className = 'led ok';
    document.getElementById('lbl-wifi').textContent = 'Connecté';
  };

  ws.onmessage = (evt) => {
    const data = JSON.parse(evt.data);

    // Mettre à jour les labels du graphique avec les vrais noms des sondes
    if (!nomsInitialises && chart && data.sondes) {
      data.sondes.forEach((s, i) => { chart.data.datasets[i].label = s.nom; });
      initLegende(data.sondes.map(s => s.nom));
      nomsInitialises = true;
    }

    // Mettre à jour les cartes
    rendreSondes(data.sondes);

    // RSSI header
    document.getElementById('lbl-rssi').textContent = data.rssi + ' dBm';
    document.getElementById('s-rssi').textContent = data.rssi + ' dBm';
    document.getElementById('s-uptime').textContent = formatUptime(data.uptime);

    // Ajouter au graphique
    if (chart) {
      const heure = new Date().toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'});
      if (chart.data.labels.length > 288) {
        chart.data.labels.shift();
        chart.data.datasets.forEach(ds => ds.data.shift());
      }
      chart.data.labels.push(heure);
      data.sondes.forEach((s, i) => {
        chart.data.datasets[i].data.push(s.erreur ? null : parseFloat(s.temp));
      });
      chart.update('none');
    }
  };

  ws.onclose = () => {
    console.log('[WS] Déconnecté — retry dans 3s');
    document.getElementById('led-wifi').className = 'led warn pulse';
    document.getElementById('lbl-wifi').textContent = 'Reconnexion...';
    setTimeout(connecterWS, 3000);
  };

  ws.onerror = () => ws.close();
}

// ── Status bar horloge ───────────────────────────────────────────────
function majHorloge() {
  document.getElementById('sb-clock').textContent =
    location.hostname + '  |  ' + new Date().toLocaleTimeString('fr-FR');
}

// ── Thème ────────────────────────────────────────────────────────────
function toggleTheme() {
  const html = document.documentElement;
  html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
}

// ── Config ───────────────────────────────────────────────────────────
function openConfig() { document.getElementById('popup-config').classList.add('open'); }
function closeConfig() { document.getElementById('popup-config').classList.remove('open'); }

async function envoyerConfig() {
  await _postConfig(
    document.getElementById('cfg-intervalle').value,
    document.getElementById('cfg-broker').value,
    document.getElementById('cfg-port').value
  );
}
async function envoyerConfigPopup() {
  await _postConfig(
    document.getElementById('pop-intervalle').value,
    document.getElementById('pop-broker').value,
    document.getElementById('pop-port').value
  );
  closeConfig();
}
async function _postConfig(intervalleMs, mqttBroker, mqttPort) {
  try {
    await fetch('/api/config', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ intervalleMs: parseInt(intervalleMs), mqttBroker, mqttPort: parseInt(mqttPort) }),
    });
  } catch(e) { console.error('Erreur config:', e); }
}

// ── Initialisation ───────────────────────────────────────────────────
async function init() {
  // Initialiser chart avec noms par défaut, puis mettre à jour via WS
  initChart(NOMS_DEFAUT);
  await chargerHistorique();
  await chargerStatus();
  connecterWS();
  setInterval(majHorloge, 1000);
  setInterval(chargerStatus, 30000);
}

init();
</script>
</body>
</html>
  • Étape 2 : Flasher le filesystem
pio run -t uploadfs

Résultat attendu : SUCCESSindex.html uploadé dans LittleFS.


Tâche 8 : Flash complet et validation série

Cette tâche nécessite le hardware.

  • Étape 1 : Flash du firmware
pio run -t upload
  • Étape 2 : Ouvrir le moniteur série
pio device monitor
  • Étape 3 : Vérifier la séquence de boot attendue
[BOOT] esp_jardin v1.0 — démarrage...
[WIFI] Connexion STA → SSID: Mon_Reseau_WiFi
[WIFI] Connecté — IP: 10.0.0.42
[mDNS] Accessible via http://esp_jardin.local
[OTA] Prêt
[FS] LittleFS monté
[HTTP] Serveur web démarré sur port 80
[SONDES] 3 capteur(s) DS18B20 détecté(s) sur GPIO 4
[BOOT] Initialisation terminée
  • Étape 4 : Vérifier les acquisitions (après 10s)
[SONDE 0] T°C Ext = 19.3°C
[SONDE 1] T°C Serre = 28.7°C
[SONDE 2] T°C Sol = 12.1°C
[WS] Client #1 connecté
  • Étape 5 : Tester l'API REST
curl http://esp_jardin.local/api/status
curl http://esp_jardin.local/api/temperatures
curl http://esp_jardin.local/api/history | head -c 200
  • Étape 6 : Tester l'interface web

Ouvrir http://esp_jardin.local dans un navigateur. Vérifier :

  • Les 3 cartes sondes affichent les températures

  • Le graphique se construit au fil des acquisitions

  • Le panneau droit affiche WiFi / MQTT / RAM / uptime

  • Étape 7 : Simuler une erreur sonde

Débrancher une sonde physique. Le log série doit afficher :

[SONDE 2] ERREUR — valeur rejetée: -127.0

L'interface web doit afficher la carte en rouge avec "Sonde déconnectée".


Protocole de validation de robustesse (post-déploiement)

Test WiFi

Éteindre le routeur WiFi pendant 60s. L'ESP doit basculer en mode AP (ESP_CHEF_JARDIN), rester accessible, puis se reconnecter automatiquement au retour du WiFi. Aucun crash, aucun blocage.

Test MQTT

Arrêter le broker Mosquitto (sudo systemctl stop mosquitto). Le log doit afficher des tentatives de reconnexion toutes les 5s sans bloquer le WebSocket ni les acquisitions. Redémarrer Mosquitto → reconnexion automatique.

Test OTA

pio run -t upload --upload-port 10.0.0.42

Le firmware doit se mettre à jour sans liaison USB.