Spec, plan d'implémentation, design system, documentation de déploiement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
52 KiB
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 : SUCCESS — index.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.