Spec, plan d'implémentation, design system, documentation de déploiement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1469 lines
52 KiB
Markdown
1469 lines
52 KiB
Markdown
# 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 :
|
||
|
||
```ini
|
||
; 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**
|
||
|
||
```markdown
|
||
# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```cpp
|
||
#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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```cpp
|
||
#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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```cpp
|
||
#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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```cpp
|
||
#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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```cpp
|
||
#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**
|
||
|
||
```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)**
|
||
|
||
```cpp
|
||
#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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
pio run -t upload
|
||
```
|
||
|
||
- [ ] **Étape 2 : Ouvrir le moniteur série**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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
|
||
```bash
|
||
pio run -t upload --upload-port 10.0.0.42
|
||
```
|
||
Le firmware doit se mettre à jour sans liaison USB.
|