#include #include #include #include #include #include #include #include #include #include #include #include #include // 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 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(); authPasswordHash = doc["auth"]["password_hash"].as(); // Charger les paramètres WiFi si disponibles if (doc["wifi"]) { if (doc["wifi"]["ssid"]) { wifiSSID = doc["wifi"]["ssid"].as(); } if (doc["wifi"]["password"]) { wifiPassword = doc["wifi"]["password"].as(); } if (doc["wifi"]["ap_password"]) { apPassword = doc["wifi"]["ap_password"].as(); } } // Charger les paramètres système si disponibles if (doc["system"]) { if (doc["system"]["hostname"]) { systemHostname = doc["system"]["hostname"].as(); } if (doc["system"]["session_timeout"]) { sessionTimeout = doc["system"]["session_timeout"].as() * 60; // Convertir minutes en secondes } if (doc["system"]["heater_max_power"]) { heaterMaxPower = doc["system"]["heater_max_power"].as(); } if (doc["system"]["min_water_temp"]) { minWaterTemp = doc["system"]["min_water_temp"].as(); } if (doc["system"]["max_water_temp"]) { maxWaterTemp = doc["system"]["max_water_temp"].as(); } if (doc["system"]["coeff_jour"]) { coeffJour = doc["system"]["coeff_jour"].as(); } if (doc["system"]["target_max_water_temp_hour"]) { targetMaxWaterTempHour = doc["system"]["target_max_water_temp_hour"].as(); } if (doc["system"]["latitude"]) { latitude = doc["system"]["latitude"].as(); } if (doc["system"]["longitude"]) { longitude = doc["system"]["longitude"].as(); } if (doc["system"]["night_start_hour"]) { nightStartHour = doc["system"]["night_start_hour"].as(); } if (doc["system"]["night_end_hour"]) { nightEndHour = doc["system"]["night_end_hour"].as(); } if (doc["system"]["operating_mode"]) { operatingMode = doc["system"]["operating_mode"].as(); 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(); } if (doc["enphase"]["token"]) { enphaseToken = doc["enphase"]["token"].as(); } if (doc["enphase"]["update_interval"]) { enphaseUpdateInterval = doc["enphase"]["update_interval"].as(); } } 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() && doc.size() >= 2) { solarProduction = round(doc[0]["activePower"].as()); // Production des panneaux arrondie if (solarProduction < 0) { solarProduction = 0; // Éviter les valeurs négatives } powerImport = round(doc[1]["activePower"].as()); // 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 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().length() > 0) { String currentPassword = requestDoc["auth"]["current_password"].as(); 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 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(); 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().length() > 0) { String newSSID = requestDoc["wifi"]["ssid"].as(); 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().length() > 0) { String newWifiPass = requestDoc["wifi"]["password"].as(); 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().length() > 0) { String newAPPass = requestDoc["wifi"]["ap_password"].as(); 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(); configDoc["system"]["hostname"] = newHostname; systemHostname = newHostname; } if (requestDoc["system"]["session_timeout"]) { int newTimeout = requestDoc["system"]["session_timeout"].as(); configDoc["system"]["session_timeout"] = newTimeout; sessionTimeout = newTimeout * 60; } if (requestDoc["system"]["heater_max_power"]) { int newPower = requestDoc["system"]["heater_max_power"].as(); configDoc["system"]["heater_max_power"] = newPower; heaterMaxPower = newPower; } if (requestDoc["system"]["min_water_temp"]) { float newTemp = requestDoc["system"]["min_water_temp"].as(); configDoc["system"]["min_water_temp"] = newTemp; minWaterTemp = newTemp; } if (requestDoc["system"]["max_water_temp"]) { float newTemp = requestDoc["system"]["max_water_temp"].as(); configDoc["system"]["max_water_temp"] = newTemp; maxWaterTemp = newTemp; } if (requestDoc["system"]["coeff_jour"]) { float newCoeff = requestDoc["system"]["coeff_jour"].as(); 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(); configDoc["system"]["target_max_water_temp_hour"] = newHour; targetMaxWaterTempHour = newHour; } if (requestDoc["system"]["latitude"]) { float newLat = requestDoc["system"]["latitude"].as(); configDoc["system"]["latitude"] = newLat; latitude = newLat; calculateSunriseSunset(); // Recalculer lever/coucher } if (requestDoc["system"]["longitude"]) { float newLon = requestDoc["system"]["longitude"].as(); configDoc["system"]["longitude"] = newLon; longitude = newLon; calculateSunriseSunset(); // Recalculer lever/coucher } if (requestDoc["system"]["night_start_hour"]) { int newHour = requestDoc["system"]["night_start_hour"].as(); configDoc["system"]["night_start_hour"] = newHour; nightStartHour = newHour; } if (requestDoc["system"]["night_end_hour"]) { int newHour = requestDoc["system"]["night_end_hour"].as(); 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(); configDoc["enphase"]["gateway_ip"] = newIP; enphaseGatewayIP = newIP; } if (requestDoc["enphase"]["token"]) { String newToken = requestDoc["enphase"]["token"].as(); configDoc["enphase"]["token"] = newToken; enphaseToken = newToken; } if (requestDoc["enphase"]["update_interval"]) { int newInterval = requestDoc["enphase"]["update_interval"].as(); 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(); } if (doc["power_import"]) { powerImport = doc["power_import"].as(); } if (doc["power_net_consumption"]) { powerNetConsumption = doc["power_net_consumption"].as(); } JsonDocument responseDoc; if (doc["water_temperature"]) { waterTemperature = doc["water_temperature"].as(); } 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 newMode = doc["mode"].as(); // 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"); } }