You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1720 lines
62 KiB

#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <HTTPClient.h>
#include <ESPAsyncWebServer.h>
#include <WiFiClientSecure.h>
#include <WiFiUdp.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Update.h>
#include <ArduinoOTA.h>
#include <esp_heap_caps.h>
#include <freertos/task.h>
// Configuration WiFi
String wifiSSID;
String wifiPassword;
String apPassword = "123456";
// Variables pour la reconnexion WiFi automatique
bool wifiAPMode = false; // true si démarré en mode Access Point
bool wifiReconnecting = false; // true pendant une tentative de reconnexion
unsigned long lastWifiReconnectAttempt = 0; // Timestamp de la dernière tentative
unsigned long wifiReconnectStartTime = 0; // Début de la tentative en cours
const unsigned long WIFI_RECONNECT_INTERVAL = 30000; // Intervalle en mode STA (60s)
const unsigned long WIFI_AP_RETRY_INTERVAL = 300000; // Intervalle depuis mode AP (5min)
const unsigned long WIFI_CONNECT_TIMEOUT = 15000; // Timeout de connexion (15s)
// Configuration de l'authentification
String authUsername = "admin";
String authPasswordHash = "";
bool isAuthenticated = false;
// Paramètres système
String systemHostname = "Routeur solaire";
int sessionTimeout = 3600; // En secondes (par défaut 1 heure)
int heaterMaxPower = 2400; // Puissance maximale du chauffe-eau en Watts
float minWaterTemp = 40.0; // Température minimale de l'eau en °C
float maxWaterTemp = 60.0; // Température maximale de l'eau en °C
float coeffJour = 350.0; // Coefficient pour calcul mode JOUR (sans unité)
int targetMaxWaterTempHour = 17; // Heure cible pour atteindre la température max (17h par défaut)
float latitude = 49.01023; // Latitude pour calcul lever/coucher soleil
float longitude = 2.03552; // Longitude pour calcul lever/coucher soleil
int nightStartHour = 0; // Heure de début de la période nuit (0h par défaut)
int nightEndHour = 4; // Heure de fin de la période nuit (4h par défaut)
// Données calculées
String sunriseTime = "--:--";
String sunsetTime = "--:--";
// Mode de fonctionnement (sur 5 bits)
// Bit 4 (16): ON
// Bit 3 (8): NUIT
// Bit 2 (4): SOLEIL
// Bit 1 (2): JOUR
// Bit 0 (0): OFF (implicite)
int operatingMode = 14; // Par défaut NUIT + SOLEIL + JOUR
// Configuration Enphase
String enphaseGatewayIP = "192.168.0.201";
String enphaseToken = "";
unsigned long enphaseUpdateInterval = 5; // En secondes
// Données de mesure en temps réel
float solarProduction = 0.0; // Production solaire en Watts
float powerImport = 0.0; // Consommation électrique inportée depuis ENEDIS en Watts
float powerNetConsumption = 0.0; // Consommation nette (net) en Watts
int heaterPower = 0; // Puissance de chauffe du chauffe-eau en % (0-100)
float waterTemperature = 0.0; // Température de l'eau du chauffe-eau en °C
// Flag pour l'état de la connexion Enphase
bool enphaseConnectionError = false;
// Configuration GPIO
#define HEATER_GPIO 12 // GPIO12 pour contrôle chauffe-eau
// Configuration GPIO
#define HEATER_GPIO 12 // GPIO12 pour contrôle chauffe-eau
// Variables pour la gestion OTA
bool otaRebootPending = false;
unsigned long otaRebootTime = 0;
// Variable pour le mode JOUR - mémoriser si la température max a été atteinte aujourd'hui
bool maxTempReachedToday = false;
// Variables pour la gestion PWM avec timer (période 100ms)
const unsigned long PWM_PERIOD = 100; // Période de 100ms en ms
volatile unsigned long pwmCycleCount = 0; // Compteur de cycles PWM
volatile unsigned long pwmOnTime = 0; // Temps ON en ms pour le cycle actuel
volatile unsigned long pwmElapsed = 0; // Temps écoulé dans le cycle actuel en ms
volatile bool pwmNeedRecalc = true; // Flag pour indiquer qu'il faut recalculer la puissance
// Variables pour la gestion des tâches FreeRTOS (Enphase sur cœur 1)
TaskHandle_t enphaseTaskHandle = NULL;
unsigned long lastEnphaseUpdate = 0;
bool enphaseUpdateRequested = false;
bool enphaseDataUpdated = false; // Flag pour indiquer que les données Enphase viennent d'être mises à jour
#ifdef ESP32
hw_timer_t *timer = NULL;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
#endif
AsyncWebServer server(80);
// Déclarations forward des fonctions
void handleFileRequest(String path);
void IRAM_ATTR onTimerISR();
void calculateHeaterPower();
void fetchEnphaseData();
// Fonction wrapper pour exécuter fetchEnphaseData sur le cœur 1
void enphaseTaskFunction(void *parameter) {
for (;;) {
// Attendre la notification de mise à jour
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// Exécuter la récupération des données Enphase sur le cœur 1
Serial.println("[ENPHASE] Mise à jour sur cœur 1");
fetchEnphaseData();
}
}
// Fonction pour créer un fichier config.json par défaut (mode AP)
bool createDefaultConfig() {
Serial.println("Création du fichier config.json par défaut...");
JsonDocument doc;
// Configuration authentification
doc["auth"]["username"] = "admin";
// Hash SHA-256 de "password"
doc["auth"]["password_hash"] = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8";
// Configuration WiFi en mode AP (pas de SSID = mode AP automatique)
doc["wifi"]["ssid"] = "";
doc["wifi"]["password"] = "";
doc["wifi"]["ap_password"] = "12345678";
// Configuration système
doc["system"]["hostname"] = "Routeur-solaire";
doc["system"]["session_timeout"] = 60;
doc["system"]["heater_max_power"] = 2400;
doc["system"]["min_water_temp"] = 40.0;
doc["system"]["max_water_temp"] = 60.0;
doc["system"]["coeff_jour"] = 350.0;
doc["system"]["target_max_water_temp_hour"] = 17;
doc["system"]["latitude"] = 49.01023;
doc["system"]["longitude"] = 2.03552;
doc["system"]["night_start_hour"] = 0;
doc["system"]["night_end_hour"] = 4;
// Configuration Enphase
doc["enphase"]["gateway_ip"] = "";
doc["enphase"]["token"] = "";
doc["enphase"]["update_interval"] = 5;
File configFile = LittleFS.open("/config.json", "w");
if (!configFile) {
Serial.println("Erreur: impossible de créer config.json");
return false;
}
serializeJsonPretty(doc, configFile);
configFile.close();
Serial.println("Fichier config.json créé avec succès (mode AP par défaut)");
return true;
}
// Fonction pour charger la configuration d'authentification
bool loadAuthConfig() {
File configFile = LittleFS.open("/config.json", "r");
if (!configFile) {
Serial.println("config.json non trouvé, création avec valeurs par défaut...");
if (!createDefaultConfig()) {
return false;
}
// Réouvrir le fichier après création
configFile = LittleFS.open("/config.json", "r");
if (!configFile) {
Serial.println("Erreur: impossible de lire le config.json créé");
return false;
}
}
size_t size = configFile.size();
std::unique_ptr<char[]> buf(new char[size]);
configFile.readBytes(buf.get(), size);
configFile.close();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, buf.get());
if (error) {
Serial.println("Erreur lors de la lecture du JSON");
return false;
}
authUsername = doc["auth"]["username"].as<String>();
authPasswordHash = doc["auth"]["password_hash"].as<String>();
// Charger les paramètres WiFi si disponibles
if (doc["wifi"]) {
if (doc["wifi"]["ssid"]) {
wifiSSID = doc["wifi"]["ssid"].as<String>();
}
if (doc["wifi"]["password"]) {
wifiPassword = doc["wifi"]["password"].as<String>();
}
if (doc["wifi"]["ap_password"]) {
apPassword = doc["wifi"]["ap_password"].as<String>();
}
}
// Charger les paramètres système si disponibles
if (doc["system"]) {
if (doc["system"]["hostname"]) {
systemHostname = doc["system"]["hostname"].as<String>();
}
if (doc["system"]["session_timeout"]) {
sessionTimeout = doc["system"]["session_timeout"].as<int>() * 60; // Convertir minutes en secondes
}
if (doc["system"]["heater_max_power"]) {
heaterMaxPower = doc["system"]["heater_max_power"].as<int>();
}
if (doc["system"]["min_water_temp"]) {
minWaterTemp = doc["system"]["min_water_temp"].as<float>();
}
if (doc["system"]["max_water_temp"]) {
maxWaterTemp = doc["system"]["max_water_temp"].as<float>();
}
if (doc["system"]["coeff_jour"]) {
coeffJour = doc["system"]["coeff_jour"].as<float>();
}
if (doc["system"]["target_max_water_temp_hour"]) {
targetMaxWaterTempHour = doc["system"]["target_max_water_temp_hour"].as<int>();
}
if (doc["system"]["latitude"]) {
latitude = doc["system"]["latitude"].as<float>();
}
if (doc["system"]["longitude"]) {
longitude = doc["system"]["longitude"].as<float>();
}
if (doc["system"]["night_start_hour"]) {
nightStartHour = doc["system"]["night_start_hour"].as<int>();
}
if (doc["system"]["night_end_hour"]) {
nightEndHour = doc["system"]["night_end_hour"].as<int>();
}
if (doc["system"]["operating_mode"]) {
operatingMode = doc["system"]["operating_mode"].as<int>();
Serial.printf("[CONFIG] Mode de fonctionnement chargé: %d\n", operatingMode);
}
}
// Charger les paramètres Enphase si disponibles
if (doc["enphase"]) {
if (doc["enphase"]["gateway_ip"]) {
enphaseGatewayIP = doc["enphase"]["gateway_ip"].as<String>();
}
if (doc["enphase"]["token"]) {
enphaseToken = doc["enphase"]["token"].as<String>();
}
if (doc["enphase"]["update_interval"]) {
enphaseUpdateInterval = doc["enphase"]["update_interval"].as<int>();
}
}
Serial.println("Configuration d'authentification chargée");
return true;
}
// Fonction pour générer un hash SHA-256
String generateSHA256(String input) {
// ESP32 utilise mbedTLS
#include "mbedtls/md.h"
uint8_t hash[32];
mbedtls_md_context_t ctx;
mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 0);
mbedtls_md_starts(&ctx);
mbedtls_md_update(&ctx, (const unsigned char*)input.c_str(), input.length());
mbedtls_md_finish(&ctx, hash);
mbedtls_md_free(&ctx);
String hashStr = "";
for (int i = 0; i < 32; i++) {
char hex[3];
sprintf(hex, "%02x", hash[i]);
hashStr += hex;
}
return hashStr;
}
// Fonction pour calculer le lever et coucher du soleil
// Algorithme simplifié basé sur les formules astronomiques
void calculateSunriseSunset() {
// Obtenir le temps actuel (nécessite NTP configuré)
time_t now = time(nullptr);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
int dayOfYear = timeinfo.tm_yday + 1;
// Calcul de l'angle du jour de l'année
float N = dayOfYear;
float longitude_hour = longitude / 15.0;
// Approximation de l'heure du lever du soleil
float t_rise = N + ((6.0 - longitude_hour) / 24.0);
float t_set = N + ((18.0 - longitude_hour) / 24.0);
// Anomalie moyenne du soleil
float M_rise = (0.9856 * t_rise) - 3.289;
float M_set = (0.9856 * t_set) - 3.289;
// Longitude vraie du soleil
float L_rise = fmod(M_rise + (1.916 * sin(M_rise * PI / 180.0)) + (0.020 * sin(2 * M_rise * PI / 180.0)) + 282.634, 360.0);
float L_set = fmod(M_set + (1.916 * sin(M_set * PI / 180.0)) + (0.020 * sin(2 * M_set * PI / 180.0)) + 282.634, 360.0);
// Ascension droite
float RA_rise = fmod(atan(0.91764 * tan(L_rise * PI / 180.0)) * 180.0 / PI, 360.0);
float RA_set = fmod(atan(0.91764 * tan(L_set * PI / 180.0)) * 180.0 / PI, 360.0);
// Ajuster RA au bon quadrant
float Lquadrant_rise = (floor(L_rise / 90.0)) * 90.0;
float RAquadrant_rise = (floor(RA_rise / 90.0)) * 90.0;
RA_rise = RA_rise + (Lquadrant_rise - RAquadrant_rise);
float Lquadrant_set = (floor(L_set / 90.0)) * 90.0;
float RAquadrant_set = (floor(RA_set / 90.0)) * 90.0;
RA_set = RA_set + (Lquadrant_set - RAquadrant_set);
// Convertir RA en heures
RA_rise = RA_rise / 15.0;
RA_set = RA_set / 15.0;
// Déclinaison du soleil
float sinDec_rise = 0.39782 * sin(L_rise * PI / 180.0);
float cosDec_rise = cos(asin(sinDec_rise));
float sinDec_set = 0.39782 * sin(L_set * PI / 180.0);
float cosDec_set = cos(asin(sinDec_set));
// Angle horaire local
float zenith = 90.833; // Angle zénith officiel pour lever/coucher
float cosH_rise = (cos(zenith * PI / 180.0) - (sinDec_rise * sin(latitude * PI / 180.0))) / (cosDec_rise * cos(latitude * PI / 180.0));
float cosH_set = (cos(zenith * PI / 180.0) - (sinDec_set * sin(latitude * PI / 180.0))) / (cosDec_set * cos(latitude * PI / 180.0));
// Vérifier si le soleil se lève/couche ce jour
if (cosH_rise > 1.0 || cosH_rise < -1.0 || cosH_set > 1.0 || cosH_set < -1.0) {
sunriseTime = "--:--";
sunsetTime = "--:--";
return;
}
// Calculer H et convertir en heures
float H_rise = 360.0 - (acos(cosH_rise) * 180.0 / PI);
float H_set = acos(cosH_set) * 180.0 / PI;
H_rise = H_rise / 15.0;
H_set = H_set / 15.0;
// Temps local
float T_rise = H_rise + RA_rise - (0.06571 * t_rise) - 6.622;
float T_set = H_set + RA_set - (0.06571 * t_set) - 6.622;
// Ajuster au fuseau horaire UTC+1 (France)
float UT_rise = fmod(T_rise - longitude_hour + 24.0, 24.0);
float UT_set = fmod(T_set - longitude_hour + 24.0, 24.0);
// Convertir en heure locale (UTC+1 ou UTC+2 selon heure d'été)
int local_rise = (int)(UT_rise + 1.0) % 24; // UTC+1 pour l'hiver
int local_set = (int)(UT_set + 1.0) % 24;
int rise_hour = (int)local_rise;
int rise_min = (int)((UT_rise + 1.0 - rise_hour) * 60.0);
int set_hour = (int)local_set;
int set_min = (int)((UT_set + 1.0 - set_hour) * 60.0);
// Formater les heures
char sunrise_str[6];
char sunset_str[6];
sprintf(sunrise_str, "%02d:%02d", rise_hour, rise_min);
sprintf(sunset_str, "%02d:%02d", set_hour, set_min);
sunriseTime = String(sunrise_str);
sunsetTime = String(sunset_str);
Serial.printf("[SUN] Lever: %s, Coucher: %s\n", sunriseTime.c_str(), sunsetTime.c_str());
}
// Fonction pour vérifier l'authentification (AsyncWebServer)
bool checkAuthentication(AsyncWebServerRequest *request) {
String cookieHeader = "";
if (request->hasHeader("Cookie")) {
cookieHeader = request->getHeader("Cookie")->value();
}
if (cookieHeader.length() == 0) {
return false;
}
// Découper tous les cookies et vérifier la présence de session=authenticated
int start = 0;
while (start < cookieHeader.length()) {
int sep = cookieHeader.indexOf(';', start);
String pair = (sep == -1) ? cookieHeader.substring(start) : cookieHeader.substring(start, sep);
pair.trim();
if (pair.startsWith("session=")) {
String value = pair.substring(String("session=").length());
value.trim();
if (value == "authenticated") {
return true;
}
}
if (sep == -1) break;
start = sep + 1;
}
return false;
}
// Interruption timer appelée toutes les 1ms (1000Hz) pour gérer le PWM
void IRAM_ATTR onTimerISR() {
// ESP32: Protéger l'accès aux variables partagées
portENTER_CRITICAL_ISR(&timerMux);
pwmElapsed += 1;
// Si on dépasse la période, recommencer un nouveau cycle
if (pwmElapsed >= PWM_PERIOD) {
pwmElapsed = 0;
pwmCycleCount++;
pwmNeedRecalc = true; // Signaler qu'il faut recalculer au prochain passage dans loop()
}
// Appliquer le PWM: ON si on est dans la fenêtre ON, sinon OFF
if (pwmElapsed < pwmOnTime) {
digitalWrite(HEATER_GPIO, HIGH);
} else {
digitalWrite(HEATER_GPIO, LOW);
}
portEXIT_CRITICAL_ISR(&timerMux);
}
// Fonction pour récupérer les données depuis la passerelle Enphase
void fetchEnphaseData() {
if (enphaseGatewayIP.length() == 0 || enphaseToken.length() == 0) {
Serial.println("Configuration Enphase manquante");
enphaseConnectionError = true;
return;
}
WiFiClientSecure client;
client.setInsecure(); // Ignorer la vérification du certificat SSL
HTTPClient http;
String url = "https://" + enphaseGatewayIP + "/ivp/meters/readings";
http.begin(client, url);
http.addHeader("Accept", "application/json");
http.addHeader("Authorization", "Bearer " + enphaseToken);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); // Suivre les redirections
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error && doc.is<JsonArray>() && doc.size() >= 2) {
solarProduction = round(doc[0]["activePower"].as<float>()); // Production des panneaux arrondie
if (solarProduction < 0) {
solarProduction = 0; // Éviter les valeurs négatives
}
powerImport = round(doc[1]["activePower"].as<float>()); // Consommation totale enedis arrondie
powerNetConsumption = round(powerImport+solarProduction); // Consommation de la maison
Serial.printf("Enphase - Total: %.1fW, Panneaux: %.1fW, Enedis: %.1fW\n",
powerNetConsumption, solarProduction, powerImport);
enphaseConnectionError = false;
enphaseDataUpdated = true; // Signaler que les données viennent d'être mises à jour
} else {
Serial.println("Erreur parsing JSON Enphase");
enphaseConnectionError = true;
}
} else {
Serial.printf("Erreur HTTP Enphase: %d\n", httpCode);
enphaseConnectionError = true;
}
http.end();
}
// Fonction pour calculer la puissance du chauffe-eau (appelée une fois par cycle PWM)
void calculateHeaterPower() {
if (!pwmNeedRecalc) {
return; // Pas besoin de recalculer
}
pwmNeedRecalc = false;
// Obtenir l'heure actuelle
time_t now = time(nullptr);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
int currentHour = timeinfo.tm_hour;
int currentMinute = timeinfo.tm_min;
int currentSecond = timeinfo.tm_sec;
// Vérifier si on est dans la période nuit
bool isNightPeriod = false;
if (nightStartHour < nightEndHour) {
// Cas normal: par exemple 0h à 4h
isNightPeriod = (currentHour >= nightStartHour && currentHour < nightEndHour);
} else {
// Cas où la période traverse minuit: par exemple 22h à 2h
isNightPeriod = (currentHour >= nightStartHour || currentHour < nightEndHour);
}
// Vérifier si on est dans la période jour (entre lever et coucher du soleil)
bool isDayPeriod = false;
if (sunriseTime != "--:--" && sunsetTime != "--:--") {
// Parser les heures de lever et coucher
int sunriseHour = sunriseTime.substring(0, 2).toInt();
int sunriseMinute = sunriseTime.substring(3, 5).toInt();
int sunsetHour = sunsetTime.substring(0, 2).toInt();
int sunsetMinute = sunsetTime.substring(3, 5).toInt();
// Convertir en minutes depuis minuit pour faciliter la comparaison
int currentTimeInMinutes = currentHour * 60 + currentMinute;
int sunriseTimeInMinutes = sunriseHour * 60 + sunriseMinute;
int sunsetTimeInMinutes = sunsetHour * 60 + sunsetMinute;
isDayPeriod = (currentTimeInMinutes >= sunriseTimeInMinutes && currentTimeInMinutes <= sunsetTimeInMinutes);
}
// Calculer l'excédent solaire et ajuster le pourcentage de chauffe
int calculatedHeaterPowerJour = 0;
static int calculatedHeaterPowerSoleil = 0;
int calculatedHeaterPower = heaterPower;
// Extraire les bits du mode
bool modeON = (operatingMode & 16) != 0; // Bit 4
bool modeNUIT = (operatingMode & 8) != 0; // Bit 3
bool modeSOLEIL = (operatingMode & 4) != 0; // Bit 2
bool modeJOUR = (operatingMode & 2) != 0; // Bit 1
bool modeOFF = (operatingMode == 0); // Tous les bits à 0
// Calculer le pourcentage de chauffe selon le mode
if (modeOFF) {
calculatedHeaterPower = 0;
} else if (modeON) {
calculatedHeaterPower = 100;
} else if (modeNUIT && isNightPeriod) {
if (waterTemperature < minWaterTemp) {
calculatedHeaterPower = 100; // Forcer 100% pendant la période nuit
// Serial.printf("[NUIT] Chauffe forcée 100%% - Temp: %.1f°C < Min: %.1f°C\n",
// waterTemperature, minWaterTemp);
} else {
calculatedHeaterPower = 0;
// Serial.printf("[NUIT] Température atteinte: %.1f°C >= Min: %.1f°C\n",
// waterTemperature, minWaterTemp);
}
} else if (modeSOLEIL || modeJOUR) {
if (modeJOUR && isDayPeriod) {
// Mode JOUR: calculer selon la formule (uniquement pendant la journée)
// calculatedHeaterPowerJour = 100 * CoeffJour * (max_water_temp - water_temperature) / (secondes_restantes)
// Vérifier si la température max a déjà été atteinte aujourd'hui
if (waterTemperature >= maxWaterTemp) {
maxTempReachedToday = true;
}
if (maxTempReachedToday) {
// La température max a été atteinte aujourd'hui, ne pas chauffer
calculatedHeaterPowerJour = 0;
} else {
// Calculer le temps actuel en secondes depuis minuit
int currentTimeInSeconds = currentHour * 3600 + currentMinute * 60 + currentSecond;
// Calculer l'heure cible en secondes depuis minuit
int targetTimeInSeconds = targetMaxWaterTempHour * 3600;
// Calculer les secondes restantes
int secondsRemaining = targetTimeInSeconds - currentTimeInSeconds;
// // Si on est après l'heure cible ou à l'heure cible, calculer jusqu'au lendemain
// if (secondsRemaining < 0) {
// secondsRemaining = 86400 + secondsRemaining; // 86400 = 24h en secondes
// }
float tempDiff = maxWaterTemp - waterTemperature;
if (tempDiff > 0 && secondsRemaining > 0) {
calculatedHeaterPowerJour = (int)(100.0 * coeffJour * tempDiff / secondsRemaining);
if (calculatedHeaterPowerJour > 100) calculatedHeaterPowerJour = 100;
if (calculatedHeaterPowerJour < 0) calculatedHeaterPowerJour = 0;
// Afficher avec les heures et minutes pour la lisibilité
// int hoursDisplay = secondsRemaining / 3600;
// int minutesDisplay = (secondsRemaining % 3600) / 60;
// Serial.printf("[JOUR] TempDiff: %.1f°C, Temps restant: %dh%02dm, Chauffe: %d%%\n",
// tempDiff, hoursDisplay, minutesDisplay, calculatedHeaterPowerJour);
} else {
calculatedHeaterPowerJour = 0;
// Serial.printf("[JOUR] Température atteinte ou dépassée: %.1f°C >= %.1f°C\n",
// waterTemperature, maxWaterTemp);
}
}
}
if (modeSOLEIL && enphaseDataUpdated) {
// Mode SOLEIL: ajuster selon l'excédent solaire (uniquement quand les données Enphase sont mises à jour)
// if (powerImport <= 0) {
// // Convertir l'excédent solaire en pourcentage (max = puissance max du chauffe-eau)
// calculatedHeaterPowerSoleil = calculatedHeaterPowerSoleil - (int)((powerImport * 100) / heaterMaxPower);
// if (calculatedHeaterPowerSoleil > 100) calculatedHeaterPowerSoleil = 100;
// } else {
// calculatedHeaterPowerSoleil = 0;
// }
calculatedHeaterPowerSoleil = calculatedHeaterPowerSoleil - (int)((powerImport * 100) / heaterMaxPower);
if (calculatedHeaterPowerSoleil > 100){
calculatedHeaterPowerSoleil = 100;
}else if (calculatedHeaterPowerSoleil <= 0){
calculatedHeaterPowerSoleil = 0;
}
enphaseDataUpdated = false; // Consommer le flag de mise à jour
}
// Prendre le maximum entre les deux modes
calculatedHeaterPower = max(calculatedHeaterPowerJour, calculatedHeaterPowerSoleil);
} else {
calculatedHeaterPower = 0;
}
// Calculer le temps ON en fonction du pourcentage calculé
noInterrupts(); // Désactiver les interruptions pendant la mise à jour
pwmOnTime = (PWM_PERIOD * calculatedHeaterPower) / 100;
heaterPower = calculatedHeaterPower; // Mettre à jour la puissance réelle affichée
interrupts(); // Réactiver les interruptions
}
void setup() {
Serial.begin(115200);
Serial.println("\n\nDémarrage ESP32 - Contrôleur Solaire");
// Initialisation de LittleFS
if (!LittleFS.begin()) {
Serial.println("Erreur lors du montage de LittleFS");
return;
}
Serial.println("LittleFS monté avec succès");
// Configuration GPIO pour le chauffe-eau
pinMode(HEATER_GPIO, OUTPUT);
digitalWrite(HEATER_GPIO, LOW);
Serial.println("GPIO12 configurée pour le chauffe-eau");
// Charger la configuration d'authentification
if (!loadAuthConfig()) {
Serial.println("ATTENTION: Impossible de charger la configuration d'authentification");
}
// Connexion WiFi
Serial.printf("Connexion à %s ", wifiSSID.c_str());
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str());
int wifiRetry = 0;
while (WiFi.status() != WL_CONNECTED && wifiRetry < 20) {
delay(500);
Serial.print(".");
wifiRetry++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connecté!");
Serial.print("Adresse IP: ");
Serial.println(WiFi.localIP());
} else {
// Échec de connexion - Passer en mode AP
Serial.println("\nÉchec de connexion WiFi!");
Serial.println("Démarrage du mode Access Point...");
WiFi.mode(WIFI_AP);
String apSSID = systemHostname;
if (WiFi.softAP(apSSID.c_str(), apPassword.c_str())) {
Serial.println("Mode AP démarré avec succès");
Serial.print("SSID: ");
Serial.println(apSSID);
Serial.print("Mot de passe: ");
Serial.println(apPassword);
Serial.print("Adresse IP: ");
Serial.println(WiFi.softAPIP());
wifiAPMode = true;
} else {
Serial.println("Erreur lors du démarrage du mode AP");
}
}
// Configuration NTP pour avoir l'heure
// ESP32: configTime(gmtOffset_sec, daylightOffset_sec, server1, server2, server3)
configTime(3600, 3600, "pool.ntp.org", "time.nist.gov");
Serial.println("Attente synchronisation NTP...");
time_t now = time(nullptr);
int retry = 0;
while (now < 8 * 3600 * 2 && retry < 15) {
delay(500);
Serial.print(".");
now = time(nullptr);
retry++;
}
Serial.println();
struct tm timeinfo;
localtime_r(&now, &timeinfo);
Serial.printf("Heure actuelle: %02d:%02d:%02d\n", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
// Calculer les heures de lever/coucher du soleil
calculateSunriseSunset();
// ===== Configuration ArduinoOTA =====
ArduinoOTA.setHostname(systemHostname.c_str());
ArduinoOTA.setPassword("password");
ArduinoOTA.onStart([]() {
String type;
if (ArduinoOTA.getCommand() == U_FLASH) {
type = "sketch";
} else { // U_SPIFFS ou U_FS
type = "filesystem";
// Démonter LittleFS avant la mise à jour
LittleFS.end();
}
Serial.println("Début de la mise à jour OTA: " + type);
});
ArduinoOTA.onEnd([]() {
Serial.println("\nMise à jour OTA terminée");
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("Progression: %u%%\r", (progress / (total / 100)));
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("Erreur[%u]: ", error);
if (error == OTA_AUTH_ERROR) {
Serial.println("Échec authentification");
} else if (error == OTA_BEGIN_ERROR) {
Serial.println("Échec au début");
} else if (error == OTA_CONNECT_ERROR) {
Serial.println("Échec de connexion");
} else if (error == OTA_RECEIVE_ERROR) {
Serial.println("Échec de réception");
} else if (error == OTA_END_ERROR) {
Serial.println("Échec à la fin");
}
});
ArduinoOTA.begin();
Serial.println("ArduinoOTA prêt");
Serial.printf("Hostname OTA: %s\n", systemHostname.c_str());
// ===== Configuration du serveur web AsyncWebServer =====
// Route POST /login
server.on("/login", HTTP_POST, [](AsyncWebServerRequest *request){
String username = "";
String password = "";
if (request->hasParam("username", true)) {
username = request->getParam("username", true)->value();
}
if (request->hasParam("password", true)) {
password = request->getParam("password", true)->value();
}
Serial.printf("[LOGIN] Tentative: user='%s'\n", username.c_str());
String passwordHash = generateSHA256(password);
if (username == authUsername && passwordHash == authPasswordHash) {
AsyncWebServerResponse *response = request->beginResponse(302, "text/plain", "");
String cookieValue = "session=authenticated; Path=/; Max-Age=" + String(sessionTimeout) + "; SameSite=Lax";
response->addHeader("Set-Cookie", cookieValue);
response->addHeader("Location", "/");
request->send(response);
Serial.println("[LOGIN] Authentification réussie");
} else {
request->send(401, "text/plain", "Identifiants incorrects");
Serial.printf("[LOGIN] Échec - hash reçu: %s, attendu: %s\n", passwordHash.c_str(), authPasswordHash.c_str());
}
});
// Route GET /login (affiche la page)
server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(LittleFS, "/login.html", "text/html");
});
// Route GET /logout
server.on("/logout", HTTP_GET, [](AsyncWebServerRequest *request){
AsyncWebServerResponse *response = request->beginResponse(302, "text/plain", "");
response->addHeader("Set-Cookie", "session=; Path=/; Max-Age=0");
response->addHeader("Location", "/login");
request->send(response);
});
// API GET /api/settings
server.on("/api/settings", HTTP_GET, [](AsyncWebServerRequest *request){
if (!checkAuthentication(request)) {
request->send(401, "text/plain", "Non autorisé");
return;
}
JsonDocument doc;
doc["auth"]["username"] = authUsername;
doc["wifi"]["ssid"] = wifiSSID;
doc["wifi"]["ap_password"] = apPassword;
// Ne pas envoyer le mot de passe WiFi pour des raisons de sécurité
doc["system"]["hostname"] = systemHostname;
doc["system"]["session_timeout"] = sessionTimeout / 60;
doc["system"]["heater_max_power"] = heaterMaxPower;
doc["system"]["min_water_temp"] = minWaterTemp;
doc["system"]["max_water_temp"] = maxWaterTemp;
doc["system"]["coeff_jour"] = coeffJour;
doc["system"]["target_max_water_temp_hour"] = targetMaxWaterTempHour;
doc["system"]["latitude"] = latitude;
doc["system"]["longitude"] = longitude;
doc["system"]["night_start_hour"] = nightStartHour;
doc["system"]["night_end_hour"] = nightEndHour;
doc["system"]["operating_mode"] = operatingMode;
doc["enphase"]["gateway_ip"] = enphaseGatewayIP;
doc["enphase"]["token"] = enphaseToken;
doc["enphase"]["update_interval"] = enphaseUpdateInterval;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// API POST /api/settings
server.on("/api/settings", HTTP_POST,
[](AsyncWebServerRequest *request){
Serial.println("[SETTINGS] POST /api/settings appelé");
// Handler appelé quand la requête est complète
if (!checkAuthentication(request)) {
Serial.println("[SETTINGS] Authentification échouée");
request->send(401, "text/plain", "Non autorisé");
return;
}
Serial.println("[SETTINGS] Authentification OK");
// Récupérer le body depuis _tempObject
if (request->_tempObject == NULL) {
Serial.println("[SETTINGS] Erreur: Pas de body reçu");
request->send(400, "text/plain", "Données manquantes");
return;
}
String body = String((char*)request->_tempObject);
free(request->_tempObject);
request->_tempObject = NULL;
Serial.println("[SETTINGS] Body reçu: " + body);
JsonDocument requestDoc;
DeserializationError error = deserializeJson(requestDoc, body);
if (error) {
Serial.println("[SETTINGS] Erreur parsing JSON: " + String(error.c_str()));
request->send(400, "text/plain", "JSON invalide");
return;
}
// Charger la configuration actuelle
File configFile = LittleFS.open("/config.json", "r");
if (!configFile) {
request->send(500, "text/plain", "Impossible d'ouvrir le fichier de configuration");
return;
}
size_t size = configFile.size();
std::unique_ptr<char[]> buf(new char[size]);
configFile.readBytes(buf.get(), size);
configFile.close();
JsonDocument configDoc;
deserializeJson(configDoc, buf.get());
// Vérifier si changement de mot de passe
if (requestDoc["auth"]["new_password"] &&
!requestDoc["auth"]["new_password"].isNull() &&
requestDoc["auth"]["new_password"].as<String>().length() > 0) {
String currentPassword = requestDoc["auth"]["current_password"].as<String>();
String currentPasswordHash = generateSHA256(currentPassword);
if (currentPasswordHash != authPasswordHash) {
request->send(403, "text/plain", "Mot de passe actuel incorrect");
return;
}
String newPassword = requestDoc["auth"]["new_password"].as<String>();
String newPasswordHash = generateSHA256(newPassword);
configDoc["auth"]["password_hash"] = newPasswordHash;
authPasswordHash = newPasswordHash;
}
// Mettre à jour le nom d'utilisateur
if (requestDoc["auth"]["username"]) {
String newUsername = requestDoc["auth"]["username"].as<String>();
configDoc["auth"]["username"] = newUsername;
authUsername = newUsername;
}
// Mettre à jour les paramètres WiFi
bool wifiChanged = false;
if (requestDoc["wifi"]) {
if (!configDoc["wifi"]) {
configDoc["wifi"] = JsonObject();
}
if (requestDoc["wifi"]["ssid"] && requestDoc["wifi"]["ssid"].as<String>().length() > 0) {
String newSSID = requestDoc["wifi"]["ssid"].as<String>();
if (newSSID != wifiSSID) {
configDoc["wifi"]["ssid"] = newSSID;
wifiSSID = newSSID;
wifiChanged = true;
Serial.println("[SETTINGS] SSID modifié, redémarrage nécessaire");
}
}
if (requestDoc["wifi"]["password"] && requestDoc["wifi"]["password"].as<String>().length() > 0) {
String newWifiPass = requestDoc["wifi"]["password"].as<String>();
if (newWifiPass != wifiPassword) {
configDoc["wifi"]["password"] = newWifiPass;
wifiPassword = newWifiPass;
wifiChanged = true;
Serial.println("[SETTINGS] Mot de passe WiFi modifié, redémarrage nécessaire");
}
}
if (requestDoc["wifi"]["ap_password"] && requestDoc["wifi"]["ap_password"].as<String>().length() > 0) {
String newAPPass = requestDoc["wifi"]["ap_password"].as<String>();
if (newAPPass.length() >= 8) {
configDoc["wifi"]["ap_password"] = newAPPass;
apPassword = newAPPass;
}
}
}
// Mettre à jour les paramètres système
if (requestDoc["system"]) {
if (!configDoc["system"]) {
configDoc["system"] = JsonObject();
}
if (requestDoc["system"]["hostname"]) {
String newHostname = requestDoc["system"]["hostname"].as<String>();
configDoc["system"]["hostname"] = newHostname;
systemHostname = newHostname;
}
if (requestDoc["system"]["session_timeout"]) {
int newTimeout = requestDoc["system"]["session_timeout"].as<int>();
configDoc["system"]["session_timeout"] = newTimeout;
sessionTimeout = newTimeout * 60;
}
if (requestDoc["system"]["heater_max_power"]) {
int newPower = requestDoc["system"]["heater_max_power"].as<int>();
configDoc["system"]["heater_max_power"] = newPower;
heaterMaxPower = newPower;
}
if (requestDoc["system"]["min_water_temp"]) {
float newTemp = requestDoc["system"]["min_water_temp"].as<float>();
configDoc["system"]["min_water_temp"] = newTemp;
minWaterTemp = newTemp;
}
if (requestDoc["system"]["max_water_temp"]) {
float newTemp = requestDoc["system"]["max_water_temp"].as<float>();
configDoc["system"]["max_water_temp"] = newTemp;
maxWaterTemp = newTemp;
}
if (requestDoc["system"]["coeff_jour"]) {
float newCoeff = requestDoc["system"]["coeff_jour"].as<float>();
configDoc["system"]["coeff_jour"] = newCoeff;
coeffJour = newCoeff;
}
if (requestDoc["system"]["target_max_water_temp_hour"]) {
int newHour = requestDoc["system"]["target_max_water_temp_hour"].as<int>();
configDoc["system"]["target_max_water_temp_hour"] = newHour;
targetMaxWaterTempHour = newHour;
}
if (requestDoc["system"]["latitude"]) {
float newLat = requestDoc["system"]["latitude"].as<float>();
configDoc["system"]["latitude"] = newLat;
latitude = newLat;
calculateSunriseSunset(); // Recalculer lever/coucher
}
if (requestDoc["system"]["longitude"]) {
float newLon = requestDoc["system"]["longitude"].as<float>();
configDoc["system"]["longitude"] = newLon;
longitude = newLon;
calculateSunriseSunset(); // Recalculer lever/coucher
}
if (requestDoc["system"]["night_start_hour"]) {
int newHour = requestDoc["system"]["night_start_hour"].as<int>();
configDoc["system"]["night_start_hour"] = newHour;
nightStartHour = newHour;
}
if (requestDoc["system"]["night_end_hour"]) {
int newHour = requestDoc["system"]["night_end_hour"].as<int>();
configDoc["system"]["night_end_hour"] = newHour;
nightEndHour = newHour;
}
}
// Mettre à jour les paramètres Enphase
if (requestDoc["enphase"]) {
if (!configDoc["enphase"]) {
configDoc["enphase"] = JsonObject();
}
if (requestDoc["enphase"]["gateway_ip"]) {
String newIP = requestDoc["enphase"]["gateway_ip"].as<String>();
configDoc["enphase"]["gateway_ip"] = newIP;
enphaseGatewayIP = newIP;
}
if (requestDoc["enphase"]["token"]) {
String newToken = requestDoc["enphase"]["token"].as<String>();
configDoc["enphase"]["token"] = newToken;
enphaseToken = newToken;
}
if (requestDoc["enphase"]["update_interval"]) {
int newInterval = requestDoc["enphase"]["update_interval"].as<int>();
configDoc["enphase"]["update_interval"] = newInterval;
enphaseUpdateInterval = newInterval;
}
}
// Sauvegarder la configuration
File writeFile = LittleFS.open("/config.json", "w");
if (!writeFile) {
Serial.println("[SETTINGS] Erreur: Impossible d'écrire le fichier");
request->send(500, "text/plain", "Impossible d'écrire le fichier de configuration");
return;
}
serializeJson(configDoc, writeFile);
writeFile.close();
Serial.println("[SETTINGS] Configuration sauvegardée avec succès");
JsonDocument responseDoc;
responseDoc["success"] = true;
responseDoc["restart"] = wifiChanged;
if (wifiChanged) {
responseDoc["message"] = "Paramètres sauvegardés. Redémarrage pour appliquer les changements WiFi...";
} else {
responseDoc["message"] = "Paramètres sauvegardés avec succès";
}
String response;
serializeJson(responseDoc, response);
request->send(200, "application/json", response);
// Redémarrer si WiFi changé
if (wifiChanged) {
Serial.println("[SETTINGS] Redémarrage pour appliquer les changements WiFi...");
delay(1000);
ESP.restart();
}
},
NULL,
// Body handler
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
// Accumuler le body dans l'objet request
if (index == 0) {
// Premier chunk: créer le buffer
Serial.printf("[SETTINGS] Réception body: %d bytes total\n", total);
if (total > 8192) {
Serial.println("[SETTINGS] ERREUR: Body trop grand!");
return;
}
request->_tempObject = malloc(total + 1);
if (request->_tempObject == NULL) {
Serial.println("[SETTINGS] ERREUR: Échec allocation mémoire!");
return;
}
}
if (request->_tempObject != NULL) {
// Copier ce chunk
memcpy((uint8_t*)request->_tempObject + index, data, len);
Serial.printf("[SETTINGS] Chunk reçu: index=%d len=%d total=%d\n", index, len, total);
if (index + len == total) {
// Dernier chunk: terminer la chaîne
((char*)request->_tempObject)[total] = '\0';
Serial.println("[SETTINGS] Body complet reçu");
}
}
});
// API GET /api/data
server.on("/api/data", HTTP_GET, [](AsyncWebServerRequest *request){
if (!checkAuthentication(request)) {
request->send(401, "text/plain", "Non autorisé");
return;
}
JsonDocument doc;
doc["solar_production"] = solarProduction;
doc["power_import"] = powerImport;
doc["power_net_consumption"] = powerNetConsumption;
doc["heater_power"] = heaterPower;
doc["water_temperature"] = waterTemperature;
doc["sunrise_time"] = sunriseTime;
doc["sunset_time"] = sunsetTime;
doc["timestamp"] = millis();
doc["enphase_connection_error"] = enphaseConnectionError;
doc["max_temp_reached_today"] = maxTempReachedToday;
doc["night_start_hour"] = nightStartHour;
doc["night_end_hour"] = nightEndHour;
doc["min_water_temp"] = minWaterTemp;
doc["max_water_temp"] = maxWaterTemp;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// API POST /api/data
server.on("/api/data", HTTP_POST, [](AsyncWebServerRequest *request){}, NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
if (!checkAuthentication(request)) {
request->send(401, "text/plain", "Non autorisé");
return;
}
String body = "";
for (size_t i = 0; i < len; i++) {
body += (char)data[i];
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, body);
if (error) {
request->send(400, "text/plain", "JSON invalide");
return;
}
if (doc["solar_production"]) {
solarProduction = doc["solar_production"].as<float>();
}
if (doc["power_import"]) {
powerImport = doc["power_import"].as<float>();
}
if (doc["power_net_consumption"]) {
powerNetConsumption = doc["power_net_consumption"].as<float>();
}
JsonDocument responseDoc;
if (doc["water_temperature"]) {
waterTemperature = doc["water_temperature"].as<float>();
}
responseDoc["success"] = true;
responseDoc["message"] = "Données mises à jour";
String response;
serializeJson(responseDoc, response);
request->send(200, "application/json", response);
});
// API GET /api/stats - Informations système (CPU, RAM, temps de fonctionnement)
server.on("/api/stats", HTTP_GET, [](AsyncWebServerRequest *request){
if (!checkAuthentication(request)) {
request->send(401, "text/plain", "Non autorisé");
return;
}
JsonDocument doc;
// Informations de mémoire
multi_heap_info_t heap_info;
heap_caps_get_info(&heap_info, MALLOC_CAP_DEFAULT);
size_t totalHeap = heap_caps_get_total_size(MALLOC_CAP_DEFAULT);
size_t freeHeap = heap_caps_get_free_size(MALLOC_CAP_DEFAULT);
size_t usedHeap = totalHeap - freeHeap;
float heapUsagePercent = (usedHeap * 100.0) / totalHeap;
doc["memory"]["total_bytes"] = (int)totalHeap;
doc["memory"]["free_bytes"] = (int)freeHeap;
doc["memory"]["used_bytes"] = (int)usedHeap;
doc["memory"]["usage_percent"] = (float)heapUsagePercent;
// PSRAM (si disponible)
size_t totalPsram = esp_spiram_get_size();
if (totalPsram > 0) {
size_t freePsram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
size_t usedPsram = totalPsram - freePsram;
float psramUsagePercent = (usedPsram * 100.0) / totalPsram;
doc["psram"]["total_bytes"] = (int)totalPsram;
doc["psram"]["free_bytes"] = (int)freePsram;
doc["psram"]["used_bytes"] = (int)usedPsram;
doc["psram"]["usage_percent"] = (float)psramUsagePercent;
}
// Temps de fonctionnement
unsigned long uptimeSeconds = millis() / 1000;
unsigned long uptimeDays = uptimeSeconds / 86400;
unsigned long uptimeHours = (uptimeSeconds % 86400) / 3600;
unsigned long uptimeMinutes = (uptimeSeconds % 3600) / 60;
unsigned long uptimeSecs = uptimeSeconds % 60;
doc["system"]["uptime_seconds"] = (unsigned long)uptimeSeconds;
doc["system"]["uptime_days"] = (unsigned long)uptimeDays;
doc["system"]["uptime_hours"] = (unsigned long)uptimeHours;
doc["system"]["uptime_minutes"] = (unsigned long)uptimeMinutes;
doc["system"]["uptime_seconds_remainder"] = (unsigned long)uptimeSecs;
// Fréquence CPU
doc["system"]["cpu_freq_mhz"] = getCpuFrequencyMhz();
// Version du firmware
doc["system"]["esp_sdk_version"] = ESP.getSdkVersion();
doc["system"]["sketch_md5"] = ESP.getSketchMD5();
// Nombre de tâches FreeRTOS
doc["system"]["task_count"] = uxTaskGetNumberOfTasks();
// Numéro d'expédition (chip ID)
doc["system"]["chip_revision"] = ESP.getChipRevision();
// Adresse MAC
uint8_t mac[6];
WiFi.macAddress(mac);
char macStr[18];
sprintf(macStr, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
doc["system"]["mac_address"] = macStr;
// Signal WiFi si connecté
if (WiFi.status() == WL_CONNECTED) {
int rssi = WiFi.RSSI();
doc["wifi"]["ssid"] = WiFi.SSID();
doc["wifi"]["rssi_dbm"] = rssi;
doc["wifi"]["signal_strength"] = "excellent"; // Valeur par défaut
if (rssi >= -50) {
doc["wifi"]["signal_strength"] = "excellent";
} else if (rssi >= -60) {
doc["wifi"]["signal_strength"] = "very good";
} else if (rssi >= -70) {
doc["wifi"]["signal_strength"] = "good";
} else if (rssi >= -80) {
doc["wifi"]["signal_strength"] = "fair";
} else {
doc["wifi"]["signal_strength"] = "weak";
}
} else {
doc["wifi"]["connected"] = false;
}
// Uptime des tâches importantes
doc["tasks"]["enphase_running"] = (enphaseTaskHandle != NULL);
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// API pour ESPEasy - Endpoint simplifié pour recevoir la température
// ESPEasy peut envoyer avec: http://[IP]/api/espeasy?temperature=[value]
server.on("/api/espeasy", HTTP_GET, [](AsyncWebServerRequest *request){
if (request->hasParam("temperature")) {
String tempStr = request->getParam("temperature")->value();
// Vérifier que la chaîne n'est pas vide
if (tempStr.length() == 0) {
Serial.println("[ESPEASY] Erreur: Valeur vide");
request->send(400, "text/plain", "Valeur vide");
return;
}
// Vérifier que c'est un nombre valide
bool isValid = true;
bool hasDot = false;
bool hasDigit = false;
for (size_t i = 0; i < tempStr.length(); i++) {
char c = tempStr.charAt(i);
// Autoriser le signe moins au début
if (i == 0 && c == '-') {
continue;
}
// Autoriser un seul point décimal
else if (c == '.' || c == ',') {
if (hasDot) {
isValid = false;
break;
}
hasDot = true;
}
// Vérifier que c'est un chiffre
else if (c >= '0' && c <= '9') {
hasDigit = true;
}
else {
isValid = false;
break;
}
}
// Doit contenir au moins un chiffre
if (!hasDigit) {
isValid = false;
}
if (!isValid) {
Serial.printf("[ESPEASY] Erreur: '%s' n'est pas un nombre valide\n", tempStr.c_str());
request->send(400, "text/plain", "Format invalide - nombre attendu");
return;
}
// Convertir et vérifier la plage raisonnable (0-100°C)
float temp = tempStr.toFloat();
if (temp < 0.0 || temp > 100.0) {
Serial.printf("[ESPEASY] Avertissement: Température hors plage: %.1f°C\n", temp);
request->send(400, "text/plain", "Température hors plage (0-100°C)");
return;
}
waterTemperature = temp;
Serial.printf("[ESPEASY] Température reçue: %.1f°C\n", waterTemperature);
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Paramètre 'temperature' manquant");
}
});
// API GET /api/mode - Récupérer le mode actuel (accessible sans auth pour applis externes)
// Exemple: http://[IP]/api/mode?mode=8 (pour activer NUIT)
server.on("/api/mode", HTTP_GET, [](AsyncWebServerRequest *request){
// Si paramètre "mode" présent, changer le mode (comme ESPEasy)
if (request->hasParam("mode")) {
String modeStr = request->getParam("mode")->value();
int newMode = modeStr.toInt();
// Valider le mode (0-31 pour 5 bits, mais limiter aux combinaisons autorisées)
if (newMode >= 0 && newMode <= 31) {
operatingMode = newMode;
Serial.printf("[MODE] Mode changé via GET: %d\n", operatingMode);
// Sauvegarder le mode dans la configuration
File configFile = LittleFS.open("/config.json", "r");
if (configFile) {
JsonDocument configDoc;
DeserializationError error = deserializeJson(configDoc, configFile);
configFile.close();
if (!error) {
configDoc["system"]["operating_mode"] = operatingMode;
configFile = LittleFS.open("/config.json", "w");
if (configFile) {
serializeJsonPretty(configDoc, configFile);
configFile.close();
Serial.println("[MODE] Mode sauvegardé dans la configuration");
}
}
}
request->send(200, "text/plain", "OK");
} else {
request->send(400, "text/plain", "Mode invalide (0-31)");
}
} else {
// Sans paramètre, vérifier auth et retourner le mode actuel en JSON
if (!checkAuthentication(request)) {
request->send(401, "text/plain", "Non autorisé");
return;
}
JsonDocument doc;
doc["mode"] = operatingMode;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
}
});
// API POST /api/mode - Changer le mode
server.on("/api/mode", HTTP_POST,
[](AsyncWebServerRequest *request){
if (!checkAuthentication(request)) {
request->send(401, "text/plain", "Non autorisé");
return;
}
if (request->_tempObject == NULL) {
Serial.println("[MODE] Erreur: Pas de body reçu");
request->send(400, "text/plain", "Données manquantes");
return;
}
String body = String((char*)request->_tempObject);
free(request->_tempObject);
request->_tempObject = NULL;
JsonDocument doc;
DeserializationError error = deserializeJson(doc, body);
if (error) {
Serial.println("[MODE] Erreur parsing JSON");
request->send(400, "text/plain", "JSON invalide");
return;
}
if (doc["mode"].is<int>()) {
int newMode = doc["mode"].as<int>();
// Valider le mode (0-31 pour 5 bits)
if (newMode >= 0 && newMode <= 31) {
operatingMode = newMode;
Serial.printf("[MODE] Mode changé: %d\n", operatingMode);
// Sauvegarder le mode dans la configuration
File configFile = LittleFS.open("/config.json", "r");
if (configFile) {
JsonDocument configDoc;
DeserializationError error = deserializeJson(configDoc, configFile);
configFile.close();
if (!error) {
configDoc["system"]["operating_mode"] = operatingMode;
configFile = LittleFS.open("/config.json", "w");
if (configFile) {
serializeJsonPretty(configDoc, configFile);
configFile.close();
Serial.println("[MODE] Mode sauvegardé dans la configuration");
}
}
}
JsonDocument responseDoc;
responseDoc["success"] = true;
responseDoc["mode"] = operatingMode;
String response;
serializeJson(responseDoc, response);
request->send(200, "application/json", response);
} else {
request->send(400, "text/plain", "Mode invalide (0-31)");
}
} else {
request->send(400, "text/plain", "Paramètre 'mode' manquant");
}
},
NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
if (index == 0) {
Serial.printf("[MODE] Réception body: %d bytes total\n", total);
request->_tempObject = malloc(total + 1);
}
if (request->_tempObject != NULL) {
memcpy((uint8_t*)request->_tempObject + index, data, len);
if (index + len == total) {
((char*)request->_tempObject)[total] = '\0';
Serial.println("[MODE] Body complet reçu");
}
}
});
// Route POST /update - Mise à jour OTA (firmware ou filesystem)
server.on("/update", HTTP_POST,NULL,
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
// Handler appelé pendant l'upload des données
if (index == 0) {
Serial.printf("[OTA] Mise à jour démarrée: %s\n", filename.c_str());
// Arrêter le timer PWM sur ESP32
if (timer != NULL) {
timerAlarmDisable(timer);
}
digitalWrite(HEATER_GPIO, LOW);
// Vérifier si c'est une mise à jour du filesystem
bool isFilesystem = request->hasArg("filesystem");
if (isFilesystem) {
LittleFS.end();
// ESP32: Utiliser UPDATE_SIZE_UNKNOWN pour détecter automatiquement
Serial.println("[OTA] Mise à jour filesystem (taille auto)");
if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) {
Update.printError(Serial);
}
} else {
// Pour le sketch
uint32_t maxSketchSpace = UPDATE_SIZE_UNKNOWN;
Serial.printf("[OTA] Espace sketch disponible\n");
if (!Update.begin(maxSketchSpace, U_FLASH)) {
Update.printError(Serial);
}
}
}
// Écrire les données
if (len) {
if (Update.write(data, len) != len) {
Update.printError(Serial);
}
}
// Finaliser
if (final) {
if (Update.end(true)) {
Serial.printf("[OTA] Mise à jour réussie: %u octets\n", index + len);
// Envoyer la réponse au client
AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "Mise à jour réussie! Redémarrage...");
response->addHeader("Connection", "close");
response->addHeader("Content-Length", "38");
request->send(response);
// Planifier le redémarrage dans 2 secondes (géré dans loop)
otaRebootPending = true;
otaRebootTime = millis() + 2000;
Serial.println("[OTA] Redémarrage planifié dans 2 secondes...");
} else {
Update.printError(Serial);
request->send(500, "text/plain", "Erreur lors de la mise à jour");
}
}
});
// Handler pour fichiers statiques (avec authentification)
server.onNotFound([](AsyncWebServerRequest *request){
String path = request->url();
// Autoriser l'accès sans authentification pour login et ressources statiques (CSS, JS, images)
bool isPublicResource = path.startsWith("/login") ||
path.endsWith(".css") ||
path.endsWith(".js") ||
path.endsWith(".png") ||
path.endsWith(".jpg") ||
path.endsWith(".ico");
if (!isPublicResource) {
if (!checkAuthentication(request)) {
AsyncWebServerResponse *response = request->beginResponse(302, "text/plain", "");
response->addHeader("Location", "/login");
request->send(response);
return;
}
}
if (path.endsWith("/")) {
path += "index.html";
}
String contentType = "text/plain";
if (path.endsWith(".html")) contentType = "text/html";
else if (path.endsWith(".css")) contentType = "text/css";
else if (path.endsWith(".js")) contentType = "application/javascript";
else if (path.endsWith(".png")) contentType = "image/png";
else if (path.endsWith(".jpg")) contentType = "image/jpeg";
else if (path.endsWith(".ico")) contentType = "image/x-icon";
else if (path.endsWith(".json")) contentType = "application/json";
if (LittleFS.exists(path)) {
request->send(LittleFS, path, contentType);
} else {
request->send(404, "text/plain", "404: Fichier non trouvé");
}
});
server.begin();
Serial.println("Serveur HTTP AsyncWebServer démarré");
Serial.print("Accédez à http://");
Serial.println(WiFi.localIP());
// Initialiser le timer pour le PWM (interruption toutes les 1ms = 1000Hz)
// ESP32: Utiliser hw_timer
// Timer 0, prescaler 80 (80MHz / 80 = 1MHz = 1µs), compte vers le haut
timer = timerBegin(0, 80, true);
timerAttachInterrupt(timer, &onTimerISR, true);
// Déclencher toutes les 1000µs = 1ms = 1000Hz
timerAlarmWrite(timer, 1000, true);
timerAlarmEnable(timer);
Serial.println("Timer PWM initialisé (1000Hz)");
// Créer la tâche FreeRTOS pour Enphase sur le cœur 1
// Priority: 2 (plus élevée que tskIDLE_PRIORITY=0 mais moins que le loop=1)
// StackSize: 4096 octets (environ 2KB pour les variables locales)
xTaskCreatePinnedToCore(
enphaseTaskFunction, // Fonction de la tâche
"EnphaseTask", // Nom de la tâche
8192, // Taille de la pile (8KB pour HTTPClient)
NULL, // Paramètre
2, // Priorité (tskIDLE_PRIORITY=0, loop=1)
&enphaseTaskHandle, // Handle de la tâche
1 // Cœur 1 (0 = cœur principal, 1 = cœur libre)
);
Serial.println("Tâche Enphase créée sur cœur 1");
}
void loop() {
// Vérifier si un redémarrage OTA est en attente
if (otaRebootPending && millis() >= otaRebootTime) {
Serial.println("[OTA] Redémarrage maintenant...");
ESP.restart();
}
// Gérer les requêtes ArduinoOTA
ArduinoOTA.handle();
// Reconnexion WiFi automatique (non-bloquant)
{
unsigned long wifiMillis = millis();
if (wifiReconnecting) {
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[WIFI] Connexion rétablie! IP: %s\n", WiFi.localIP().toString().c_str());
wifiReconnecting = false;
if (wifiAPMode) {
// Redémarrer pour passer proprement en mode STA
Serial.println("[WIFI] Redémarrage pour basculer en mode STA...");
delay(500);
ESP.restart();
}
} else if (wifiMillis - wifiReconnectStartTime >= WIFI_CONNECT_TIMEOUT) {
Serial.println("[WIFI] Timeout reconnexion");
wifiReconnecting = false;
if (wifiAPMode) {
// Rétablir le mode AP seul
WiFi.mode(WIFI_AP);
WiFi.softAP(systemHostname.c_str(), apPassword.c_str());
Serial.println("[WIFI] Retour mode AP");
}
}
} else if (wifiSSID.length() > 0) {
if (!wifiAPMode && WiFi.status() != WL_CONNECTED &&
wifiMillis - lastWifiReconnectAttempt >= WIFI_RECONNECT_INTERVAL) {
lastWifiReconnectAttempt = wifiMillis;
wifiReconnectStartTime = wifiMillis;
wifiReconnecting = true;
Serial.println("[WIFI] Connexion perdue, tentative de reconnexion...");
WiFi.reconnect();
} else if (wifiAPMode &&
wifiMillis - lastWifiReconnectAttempt >= WIFI_AP_RETRY_INTERVAL) {
lastWifiReconnectAttempt = wifiMillis;
wifiReconnectStartTime = wifiMillis;
wifiReconnecting = true;
Serial.println("[WIFI] Mode AP: tentative de connexion au réseau configuré...");
WiFi.mode(WIFI_AP_STA);
WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str());
}
}
}
// Calculer la puissance du chauffe-eau au début de chaque cycle PWM
calculateHeaterPower();
// Récupération des données Enphase (non-bloquant avec tâche sur cœur 1)
unsigned long currentMillis = millis();
if (currentMillis - lastEnphaseUpdate >= (enphaseUpdateInterval * 1000)) {
lastEnphaseUpdate = currentMillis;
// Notifier la tâche Enphase pour mettre à jour les données
// Cette opération est non-bloquante et s'exécute sur le cœur 1
if (enphaseTaskHandle != NULL) {
xTaskNotifyGive(enphaseTaskHandle);
Serial.printf("[LOOP] Mise à jour Enphase déléguée à cœur 1 (interval: %lus)\n", enphaseUpdateInterval);
}
}
// Recalculer lever/coucher du soleil chaque jour à minuit
static int lastDay = -1;
time_t now = time(nullptr);
struct tm timeinfo;
localtime_r(&now, &timeinfo);
if (timeinfo.tm_mday != lastDay) {
lastDay = timeinfo.tm_mday;
calculateSunriseSunset();
// Réinitialiser le flag de température max atteinte pour le nouveau jour
maxTempReachedToday = false;
Serial.println("[JOUR] Nouveau jour - réinitialisation du flag température max");
}
}