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
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"); |
|
} |
|
} |