commit c670cde9cf964666949498a92b7dc2d523f2bf10 Author: scayac Date: Tue Jan 13 20:45:08 2026 +0100 Premier commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e64067 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# ESP32 Contrôleur Solaire pour Chauffe-Eau + +Application ESP32 avec serveur web, stockage LittleFS et mises à jour OTA. + +## Fonctionnalités + +- ✅ Serveur web avec stockage HTML sur LittleFS +- ✅ Protection par mot de passe (authentification SHA-256) +- ✅ Page d'accueil "Hello World" à la racine +- ✅ Interface de mise à jour OTA accessible via `/update.html` +- ✅ Mise à jour du firmware via OTA +- ✅ Mise à jour du filesystem via OTA +- ✅ Support ArduinoOTA pour mise à jour via l'IDE + +## Configuration + +### 1. Modifier les identifiants WiFi + +Éditez `src/main.cpp` et modifiez les lignes suivantes: + +```cpp +const char* ssid = "VotreSSID"; // Votre nom de réseau WiFi +const char* password = "VotreMotDePasse"; // Votre mot de passe WiFi +``` + +### 2. Configuration OTA (optionnel) + +```cpp +const char* otaHostname = "ESP32-Controleur-Solaire"; // Nom de l'appareil sur le réseau +const char* otaPassword = "admin"; // Mot de passe pour OTA via IDE +``` + +### 3. Configuration de l'authentification web + +Par défaut, l'accès au serveur web est protégé par mot de passe: +- **Utilisateur**: `admin` +- **Mot de passe**: `password` + +#### Changer le mot de passe: + +1. **Générer le hash SHA-256 de votre nouveau mot de passe:** + + Sous Linux/Mac: + ```bash + echo -n "votre_nouveau_mot_de_passe" | sha256sum + ``` + + Sous Windows (PowerShell): + ```powershell + $stringAsStream = [System.IO.MemoryStream]::new() + $writer = [System.IO.StreamWriter]::new($stringAsStream) + $writer.write("votre_nouveau_mot_de_passe") + $writer.Flush() + $stringAsStream.Position = 0 + Get-FileHash -InputStream $stringAsStream -Algorithm SHA256 | Select-Object Hash + ``` + + Ou utilisez un outil en ligne: https://emn178.github.io/online-tools/sha256.html + +2. **Éditer le fichier `data/config.json`:** + ```json + { + "auth": { + "username": "admin", + "password_hash": "VOTRE_NOUVEAU_HASH_ICI" + } + } + ``` + +3. **Re-uploader le filesystem:** + ```bash + platformio run --target uploadfs + ``` + +**⚠️ Important**: Le mot de passe est stocké sous forme de hash SHA-256 dans le fichier de configuration pour plus de sécurité. + +## Installation + +### Première installation (câble USB) + +1. **Compiler et uploader le code:** + ```bash + platformio run --target upload + ``` + +2. **Uploader le filesystem LittleFS:** + ```bash + platformio run --target uploadfs + ``` + +### Mises à jour ultérieures + +#### Via l'interface web `/update.html`: + +1. Accédez à `http://[IP_de_votre_ESP]/update.html` +2. Sélectionnez le fichier `.bin` approprié: + - **Firmware**: `.pio/build/esp32/firmware.bin` + - **Filesystem**: `.pio/build/esp32/littlefs.bin` +3. Cliquez sur le bouton d'upload correspondant + +#### Via ArduinoOTA (depuis PlatformIO): + +```bash +# Mise à jour du firmware +platformio run --target upload --upload-port [IP_de_votre_ESP] + +# Mise à jour du filesystem +platformio run --target uploadfs --upload-port [IP_de_votre_ESP] +``` + +## Structure du projet + +``` +esp32_controleur_solaire/ +├── platformio.ini # Configuration PlatformIO +├── src/ +│ └── main.cpp # Code principal +├── data/ # Fichiers pour LittleFS +│ ├── config.json # Configuration (hash du mot de passe) +│ ├── index.html # Console principale (tout CSS inclus inline) +│ ├── login.html # Page de connexion (tout CSS inclus inline) +│ ├── update.html # Interface de mise à jour OTA (tout CSS inclus inline) +│ └── settings.html # Page de configuration (tout CSS inclus inline) +├── include/ +├── lib/ +└── test/ +``` + + +## URLs disponibles + +- **http://[IP]/login** - Page de connexion +- **http://[IP]/** - Console principale (protégée) +- **http://[IP]/settings.html** - Configuration (protégée) +- **http://[IP]/update.html** - Interface de mise à jour OTA (protégée) +- **http://[IP]/logout** - Déconnexion + +**Note**: Toutes les pages sauf `/login` nécessitent une authentification. + +## Moniteur série + +Pour voir les logs de démarrage: + +```bash +platformio device monitor +``` + +Vous verrez: +- L'adresse IP attribuée +- Le statut de montage de LittleFS +- Les informations de connexion WiFi +- Les événements OTA + +## Dépannage + +### Impossible de se connecter (authentification) +- Vérifiez les identifiants par défaut: `admin` / `password` +- Assurez-vous que le fichier `config.json` a été uploadé avec le filesystem +- Vérifiez les logs série pour confirmer que la configuration a été chargée +- Effacez les cookies de votre navigateur et réessayez + +### LittleFS ne monte pas +- Assurez-vous d'avoir uploadé le filesystem avec `uploadfs` +- Vérifiez que `board_build.filesystem = littlefs` est dans `platformio.ini` + +### Impossible de se connecter au WiFi +- Vérifiez les identifiants WiFi dans `main.cpp` +- Vérifiez la force du signal WiFi + + +### OTA ne fonctionne pas +- Vérifiez que l'ESP et votre ordinateur sont sur le même réseau +- Vérifiez le mot de passe OTA si vous utilisez ArduinoOTA +- Assurez-vous que le port 8266 n'est pas bloqué par un pare-feu + +### Style CSS +- Tous les styles sont désormais inclus directement dans chaque fichier HTML (``). +- Le fichier `style.css` a été supprimé, il n'est plus nécessaire. + +## Licence + +MIT diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..703f481 --- /dev/null +++ b/data/config.json @@ -0,0 +1,30 @@ +{ + "auth": { + "username": "admin", + "password_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + }, + "wifi": { + "ssid": "Livebox-9DBA", + "password": "keyWifi-78955", + "ap_password": "123456" + }, + "system": { + "hostname": "Routeur-solaire", + "session_timeout": 60, + "heater_max_power": 2400, + "min_water_temp": 40.0, + "max_water_temp": 60.0, + "coeff_jour": 350.0, + "target_max_water_temp_hour": 17, + "latitude": 49.01023, + "longitude": 2.03552, + "night_start_hour": 0, + "night_end_hour": 4 + }, + "enphase": { + "gateway_ip": "192.168.0.201", + "token": "eyJraWQiOiI3ZDEwMDA1ZC03ODk5LTRkMGQtYmNiNC0yNDRmOThlZTE1NmIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiI0ODIzMzAwOTIzNTgiLCJpc3MiOiJFbnRyZXoiLCJlbnBoYXNlVXNlciI6Im93bmVyIiwiZXhwIjoxNzk5NzA1MDI3LCJpYXQiOjE3NjgxNjkwMjcsImp0aSI6IjYwOGZiMzI4LWQ2MGQtNDA5NC05YWIyLWNhYTUzN2Q2NGZiZSIsInVzZXJuYW1lIjoiY2hyaXN0b3BoZS5zY2F5YUBnbWFpbC5jb20ifQ.UtMHxb1E2_3IfTFzn6sZZ7J1Bc-cNezTo-0cio6djc_fRpHbUfvZkWmhotiQUntf_k6Kv2v3HPhmPZFE27Yy6Q", + "update_interval": 5 + }, + "comment": "Le hash ci-dessus correspond au mot de passe 'password'. Utilisez SHA-256 pour générer votre propre hash. session_timeout est en minutes. update_interval est en secondes. ap_password est le mot de passe du mode Access Point de secours (minimum 8 caractères)." +} diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..625bea8 --- /dev/null +++ b/data/index.html @@ -0,0 +1,509 @@ + + + + + + Contrôleur Solaire - Console + + + +
+ + +

☀️ Contrôleur Solaire pour Chauffe-Eau

+ +
+
Chargement des données...
+ +
+
+
+ Connecté +
+
+ 🌅 --:-- + 🌇 --:-- + Dernière mise à jour: -- +
+
+ + +
+ + + + diff --git a/data/login.html b/data/login.html new file mode 100644 index 0000000..2e45603 --- /dev/null +++ b/data/login.html @@ -0,0 +1,153 @@ + + + + + + Contrôleur Solaire - Connexion + + + +
+
🔒
+

Connexion requise

+
+
+
+ + +
+
+ + +
+ +
+
+ + + + diff --git a/data/settings.html b/data/settings.html new file mode 100644 index 0000000..3571ce4 --- /dev/null +++ b/data/settings.html @@ -0,0 +1,487 @@ + + + + + + Contrôleur Solaire - Configuration + + + +
+

⚙️ Configuration

+ +
+ + +
+ +

🔐 Authentification

+
+
+ + +
+ +
+ + +
Requis uniquement si vous changez le mot de passe
+
+ +
+ + +
Minimum 8 caractères recommandé
+
+ +
+ + +
+
+ + +

🌐 Configuration Système

+
+
+ + +
Nom de l'appareil sur le réseau
+
+ +
+ + +
Durée avant déconnexion automatique (5-1440 min)
+
+ +
+ + +
SSID du réseau WiFi auquel se connecter
+
+ +
+ + +
⚠️ Attention: Un redémarrage sera nécessaire pour appliquer les changements WiFi
+
+ +
+ + +
Mot de passe pour se connecter à l'ESP si impossible de rejoindre le WiFi (minimum 8 caractères)
+
+
+ + +

☀️ Configuration routeur

+
+ +
+ + +
Puissance maximale du chauffe-eau
+
+ +
+ + +
Température minimale de l'eau du chauffe-eau (mode NUIT)
+
+ +
+ + +
Température cible maximale de l'eau (mode JOUR)
+
+ +
+ + +
Coefficient pour le calcul de puissance en mode JOUR
+
+ +
+ + +
Heure à laquelle l'eau doit atteindre la température maximale (mode JOUR)
+
+ +
+ + +
Latitude pour calcul lever/coucher du soleil
+
+ +
+ + +
Longitude pour calcul lever/coucher du soleil
+
+ +
+ + +
Heure de début de la période nuit pour le mode NUIT
+
+ +
+ + +
Heure de fin de la période nuit pour le mode NUIT
+
+
+ + +

☀️ Passerelle Enphase

+
+
+ + +
Adresse IP locale de votre passerelle Enphase Envoy
+
+ +
+ + +
Token JWT Bearer pour l'API Enphase (obtenu depuis entrez.enphaseenergy.com)
+
+ +
+ + +
Fréquence de récupération des données (2-60 sec)
+
+
+ + +
+ + +
+
+ + +
+ + + + diff --git a/data/update.html b/data/update.html new file mode 100644 index 0000000..ae1f29e --- /dev/null +++ b/data/update.html @@ -0,0 +1,283 @@ + + + + + + Contrôleur - Mise à jour + + + +
+

🔄 Mise à jour OTA

+ +
+

📱 Mise à jour du Firmware

+

Sélectionnez un fichier .bin pour mettre à jour le firmware de l'ESP

+ +
+
0%
+
+
+ +
+ +
+

📁 Mise à jour du Filesystem

+

Sélectionnez un fichier .bin du filesystem LittleFS

+ +
+
0%
+
+
+ +
+ + +
+ + + + diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..f650d0b --- /dev/null +++ b/platformio.ini @@ -0,0 +1,15 @@ +[env:esp32] +platform = espressif32 +board = esp32dev +framework = arduino +board_build.filesystem = littlefs +monitor_speed = 115200 +lib_deps = + https://github.com/me-no-dev/ESPAsyncWebServer.git + https://github.com/me-no-dev/AsyncTCP.git + bblanchon/ArduinoJson@^7.2.1 +; Configuration ArduinoOTA pour firmware et filesystem +;upload_protocol = espota +;upload_port = 192.168.0.28 +;upload_flags = +; --auth=password \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..f05f59d --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,1409 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Configuration WiFi +String wifiSSID; +String wifiPassword; +String apPassword = "123456"; + +// 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 powerConsumption = 0.0; // Consommation électrique 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 + +// Configuration GPIO +#define HEATER_GPIO 12 // GPIO12 pour contrôle chauffe-eau + +// Variables pour la gestion PWM avec timer (période 1s) +const unsigned long PWM_PERIOD = 1000; // Période de 1 seconde 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 + +#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(); + +// 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(); + } + } + + // 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 10ms (100Hz) pour gérer le PWM +void IRAM_ATTR onTimerISR() { + // ESP32: Protéger l'accès aux variables partagées + portENTER_CRITICAL_ISR(&timerMux); + + pwmElapsed += 10; + + // 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"); + return; + } + + WiFiClientSecure client; + client.setInsecure(); // Ignorer la vérification du certificat SSL + HTTPClient http; + + String url = "https://" + enphaseGatewayIP + "/ivp/meters/reports/consumption"; + + 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) { + // Selon le code Python: + // json[0]['cumulative']['currW'] = total (consommation totale) + // json[1]['cumulative']['currW'] = net (réseau Enedis) + // panneaux = total - net (production solaire) + + float total = doc[0]["cumulative"]["currW"].as(); + float net = doc[1]["cumulative"]["currW"].as(); + + solarProduction = round(total - net); // Production des panneaux arrondie + powerConsumption = round(total); // Consommation totale arrondie + + + Serial.printf("Enphase - Total: %.1fW, Panneaux: %.1fW, Net: %.1fW\n", + total, solarProduction, net); + } else { + Serial.println("Erreur parsing JSON Enphase"); + } + } else { + Serial.printf("Erreur HTTP Enphase: %d\n", httpCode); + } + + 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); + } + + // Calculer l'excédent solaire et ajuster le pourcentage de chauffe + float solarExcess = solarProduction - powerConsumption; + int calculatedHeaterPowerJour, 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) { + // Mode JOUR: calculer selon la formule + // calculatedHeaterPowerJour = 100 * CoeffJour * (max_water_temp - water_temperature) / (secondes_restantes) + + // 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) { + // Mode SOLEIL: ajuster selon l'excédent solaire + if (solarExcess > 0) { + // Convertir l'excédent solaire en pourcentage (max = puissance max du chauffe-eau) + calculatedHeaterPowerSoleil = (int)((solarExcess * 100) / heaterMaxPower); + if (calculatedHeaterPowerSoleil > 100) calculatedHeaterPowerSoleil = 100; + // Serial.printf("[SOLEIL] Excédent: %.1fW, Chauffe: %d%%\n", solarExcess, calculatedHeaterPowerSoleil); + } else { + calculatedHeaterPowerSoleil = 0; + // Serial.printf("[SOLEIL] Pas d'excédent solaire (%.1fW), chauffe-eau désactivé\n", solarExcess); + } + } + // 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.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()); + } 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["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){ + // Handler appelé quand la requête est complète + if (!checkAuthentication(request)) { + request->send(401, "text/plain", "Non autorisé"); + return; + } + + // 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(); + configDoc["wifi"]["ssid"] = newSSID; + wifiSSID = newSSID; + wifiChanged = true; + } + + if (requestDoc["wifi"]["password"] && requestDoc["wifi"]["password"].as().length() > 0) { + String newWifiPass = requestDoc["wifi"]["password"].as(); + configDoc["wifi"]["password"] = newWifiPass; + wifiPassword = newWifiPass; + wifiChanged = true; + } + + 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; + + 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); + request->_tempObject = malloc(total + 1); + } + + if (request->_tempObject != NULL) { + // Copier ce chunk + memcpy((uint8_t*)request->_tempObject + index, data, len); + + 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_consumption"] = powerConsumption; + doc["heater_power"] = heaterPower; + doc["water_temperature"] = waterTemperature; + doc["sunrise_time"] = sunriseTime; + doc["sunset_time"] = sunsetTime; + doc["timestamp"] = millis(); + + 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_consumption"]) { + powerConsumption = doc["power_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 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); + 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.containsKey("mode")) { + 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); + + 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, + [](AsyncWebServerRequest *request) { + // Handler appelé après la fin de l'upload + bool shouldReboot = !Update.hasError(); + + if (Update.hasError()) { + request->send(500, "text/plain", "Erreur lors de la mise à jour"); + } else { + request->send(200, "text/plain", "Mise à jour réussie! Redémarrage..."); + } + + if (shouldReboot) { + delay(1000); + ESP.restart(); + } + }, + [](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); + } else { + Update.printError(Serial); + } + } + }); + + // 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 10ms = 100Hz) + // 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 10000µs = 10ms = 100Hz + timerAlarmWrite(timer, 10000, true); + timerAlarmEnable(timer); + Serial.println("Timer PWM initialisé (100Hz)"); +} + +void loop() { + // Gérer les requêtes ArduinoOTA + ArduinoOTA.handle(); + + // Calculer la puissance du chauffe-eau au début de chaque cycle PWM + calculateHeaterPower(); + + // Récupération des données Enphase + static unsigned long lastEnphaseUpdate = 0; + unsigned long currentMillis = millis(); + + if (currentMillis - lastEnphaseUpdate >= (enphaseUpdateInterval * 1000)) { + lastEnphaseUpdate = currentMillis; + fetchEnphaseData(); + } + + // 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(); + } +} \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html