From 4a2bdcea2774c9c813c31903035194c25c37342e Mon Sep 17 00:00:00 2001 From: FunFR <1670065+FunFR@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:04:34 +0100 Subject: [PATCH] feat: add WES v2 Cartelectronic energy sensor integration --- JS_Brute.h | 4 + JS_Para.h | 16 ++- PageBrute.h | 7 ++ PagePara.h | 23 +++++ README.md | 2 +- Server.ino | 3 + Solar_Router_V17_06.ino | 11 ++ Source_WesV2.ino | 215 ++++++++++++++++++++++++++++++++++++++++ Stockage.ino | 6 ++ 9 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 Source_WesV2.ino diff --git a/JS_Brute.h b/JS_Brute.h index ae4354e..ccbf80e 100644 --- a/JS_Brute.h +++ b/JS_Brute.h @@ -433,6 +433,10 @@ async function LoadData() { GID('infoPmqtt').style.display="block"; GH('dataPmqtt', groupes[1]); break; + case "WesV2": + GID('infoWesV2').style.display="block"; + GH('dataWesV2', groupes[1]); + break; case "Linky": GID('infoLinky').style.display = "block"; diff --git a/JS_Para.h b/JS_Para.h index e3b53a6..2bdf1a1 100644 --- a/JS_Para.h +++ b/JS_Para.h @@ -112,6 +112,9 @@ function SetParaFixe() { GID("EnphaseUser").value = F.EnphaseUser; GID("EnphasePwd").value = F.EnphasePwd; GID("EnphaseSerial").value = F.EnphaseSerial; + GID("WesUser").value = F.WesUser; + GID("WesPwd").value = F.WesPwd; + GID("WesPinceNum").value = F.WesPinceNum; GID("TopicP").value = F.TopicP; GID("MQTTRepet").value = F.MQTTRepet; GID("MQTTIP").value = int2ip(F.MQTTIP); @@ -201,6 +204,9 @@ function SendValues() { F.EnphaseUser = GID("EnphaseUser").value ; F.EnphasePwd = GID("EnphasePwd").value ; F.EnphaseSerial = GID("EnphaseSerial").value ; + F.WesUser = GID("WesUser").value ; + F.WesPwd = GID("WesPwd").value ; + F.WesPinceNum = GID("WesPinceNum").value ; F.nomRouteur =GID("nomRouteur").value.trim() ; F.nomSondeFixe = GID("nomSondeFixe").value.trim(); @@ -483,6 +489,9 @@ function AdaptationSource() {
Shelly Em Gen3
Courant maison sur voie 0 = 30, voie 1 = 31
`; break; + case 'WesV2': + txtExt = "WES v2 Cartelectronic"; + break; } // Mise à jour des libellés @@ -490,13 +499,16 @@ function AdaptationSource() { GH('label_enphase_shelly', lab_enphaseShelly); // Visibilité de la ligne d'IP externe/Référence - const isExternalSource = ['Ext', 'Enphase', 'SmartG', 'HomeW', 'ShellyEm', 'ShellyPro'].includes(F.Source); + const isExternalSource = ['Ext', 'Enphase', 'SmartG', 'HomeW', 'ShellyEm', 'ShellyPro', 'WesV2'].includes(F.Source); GID('ligneExt').style.display = isExternalSource ? "table-row" : "none"; - // Visibilité des options d'authentification/série Enphase/Shelly + // Visibilité des options d'authentification/série Enphase/Shelly/WES GID('ligneEnphaseUser').style.display = (F.Source === 'Enphase') ? "table-row" : "none"; GID('ligneEnphasePwd').style.display = (F.Source === 'Enphase') ? "table-row" : "none"; GID('ligneEnphaseSerial').style.display = (F.Source === 'Enphase' || F.Source === 'ShellyEm' || F.Source === 'ShellyPro') ? "table-row" : "none"; + GID('ligneWesUser').style.display = (F.Source === 'WesV2') ? "table-row" : "none"; + GID('ligneWesPwd').style.display = (F.Source === 'WesV2') ? "table-row" : "none"; + GID('ligneWesPinceNum').style.display = (F.Source === 'WesV2') ? "table-row" : "none"; } /** diff --git a/PageBrute.h b/PageBrute.h index 7c0d63c..9c78de7 100644 --- a/PageBrute.h +++ b/PageBrute.h @@ -70,6 +70,7 @@ const char *PageBrute = R"====( #infoSmartG, #infoHomeW, #infoShellyEm, + #infoWesV2, #infoPmqtt { display: none; } @@ -168,6 +169,12 @@ const char *PageBrute = R"====(
+ +
+
Données WES v2 Cartelectronic
+
+
+
diff --git a/PagePara.h b/PagePara.h index 3a1e36e..25783c0 100644 --- a/PagePara.h +++ b/PagePara.h @@ -24,6 +24,7 @@ const char *ParaHtml = R"====( .Bgeneraux { border: inset 4px azure; } #BoutonsBas { text-align:center; } #ligneFixe, .ligneTemperature, #ligneExt, #ligneEnphaseUser, #ligneEnphasePwd, #ligneEnphaseSerial, + #ligneWesUser, #ligneWesPwd, #ligneWesPinceNum, #infoIP, #ligneTopicP, #ligneTopicT { display:none; } .Zone, .generaux { width:100%; border:1px solid grey; border-radius:10px; margin-top:10px; background-color:rgba(30,30,30,0.3); } @@ -347,6 +348,7 @@ const char *ParaHtml = R"====( + @@ -381,6 +383,27 @@ const char *ParaHtml = R"====( onchange="checkDisabled();" autocomplete="on">
+
+ + +
+ +
+ + +
+ +
+ + +
+
diff --git a/README.md b/README.md index 2c65f4a..aa9f689 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Routeur photovoltaïque basé sur **ESP32**, permettant d’optimiser l’autoco - ⚙️ **Mesures de puissance multi-sources** - Lecture directe du **compteur Linky** via **prise TIC**. - Mesure par méthode **UxI**, **UxIx2**, ou **UxIx3** à l’aide de sondes de courant. - - Support des capteurs externes via **MQTT**, **Shelly EM**, etc. + - Support des capteurs externes via **MQTT**, **Shelly EM**, **Enphase Envoy**, **WES v2 Cartelectronic**, etc. - 🔌 **Pilotage des charges** - Sorties pour **relais statiques (SSR)**. diff --git a/Server.ino b/Server.ino index 379779b..3087114 100644 --- a/Server.ino +++ b/Server.ino @@ -290,6 +290,9 @@ void handleAjaxRMS() { // Envoi des dernières données brutes reçues du RMS if (Source_data == "UxIx3") { S += GS + MK333_dataBrute; } + if (Source_data == "WesV2") { + S += GS + Wes_dataBrute; + } if (Source_data == "Pmqtt") { S += GS + P_MQTT_Brute; } diff --git a/Solar_Router_V17_06.ino b/Solar_Router_V17_06.ino index f18e6c3..4d79fa7 100644 --- a/Solar_Router_V17_06.ino +++ b/Solar_Router_V17_06.ino @@ -616,6 +616,12 @@ float PwMQTT = 0; float PvaMQTT = 0; float PfMQTT = 1; +//Paramètres for WES v2 +String Wes_dataBrute = ""; +String WesUser = "admin"; +String WesPwd = ""; +byte WesPinceNum = 1; + //Paramètres pour RTE byte TempoRTEon = 0; int LastHeureRTE = -1; @@ -1383,6 +1389,11 @@ void Task_LectureRMS(void *pvParameters) { LastRMS_Millis = millis(); PeriodeProgMillis = 300 + ralenti; //On adapte la vitesse pour ne pas surchargé Wifi } + if (Source == "WesV2") { + LectureWesV2(); + LastRMS_Millis = millis(); + PeriodeProgMillis = 300 + ralenti; //On adapte la vitesse pour ne pas surcharger Wifi + } if (Source == "Ext") { CallESP32_Externe(); diff --git a/Source_WesV2.ino b/Source_WesV2.ino new file mode 100644 index 0000000..2f8ee5e --- /dev/null +++ b/Source_WesV2.ino @@ -0,0 +1,215 @@ +// **************************************************** +// * Client WES v2 Cartelectronic - Pinces Ampèremétriques * +// **************************************************** + +// Constantes pour les timeouts +const unsigned long WES_CONNECT_TIMEOUT = 3000; // Timeout de connexion en ms +const unsigned long WES_READ_TIMEOUT = 5000; // Timeout de lecture en ms + +// Fonction pour extraire une valeur XML simple +// Exemple: ValXml("I1", xmlData) retourne "2.96" depuis "2.96" +float ValXml(String nom, String Xml) { + String baliseDebut = "<" + nom + ">"; + String baliseFin = ""; + int p1 = Xml.indexOf(baliseDebut); + if (p1 < 0) + return 0; + + p1 += baliseDebut.length(); + int p2 = Xml.indexOf(baliseFin, p1); + if (p2 < 0) + return 0; + + String valeur = Xml.substring(p1, p2); + valeur.trim(); + return valeur.toFloat(); +} + +// Fonction pour encoder en Base64 (pour HTTP Basic Auth) +String base64Encode(String str) { + const char *base64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + String encoded = ""; + int val = 0; + int valb = -6; + + for (unsigned char c : str) { + val = (val << 8) + c; + valb += 8; + while (valb >= 0) { + encoded += base64_chars[(val >> valb) & 0x3F]; + valb -= 6; + } + } + + if (valb > -6) { + encoded += base64_chars[((val << 8) >> (valb + 8)) & 0x3F]; + } + + while (encoded.length() % 4) { + encoded += '='; + } + + return encoded; +} + +void LectureWesV2() { + String Wes_Data = ""; + float current = 0; // Intensité en Ampères + float voltage = 0; // Tension en Volts + float cosPhi = 0; // Facteur de puissance + float energyIndex = 0; // Index énergie totale en kWh + float energyInject = 0; // Index énergie injectée en kWh + + // Connexion HTTP au WES v2 + WiFiClient clientESP_RMS; + String host = IP2String(RMSextIP); + + if (!clientESP_RMS.connect(host.c_str(), 80, WES_CONNECT_TIMEOUT)) { + delay(500); + if (!clientESP_RMS.connect(host.c_str(), 80, WES_CONNECT_TIMEOUT)) { + delay(100); + clientESP_RMS.stop(); + StockMessage("WES v2: Connection failed to " + host + " - Check IP and network"); + return; + } + } + + // Préparation de l'authentification HTTP Basic + String auth = WesUser + ":" + WesPwd; + String authEncoded = base64Encode(auth); + + // Requête HTTP GET vers data.cgx + String url = "/data.cgx"; + clientESP_RMS.print(String("GET ") + url + " HTTP/1.1\r\n" + "Host: " + host + + "\r\n" + "Authorization: Basic " + authEncoded + "\r\n" + + "Connection: close\r\n\r\n"); + + unsigned long timeout = millis(); + while (clientESP_RMS.available() == 0) { + if (millis() - timeout > WES_READ_TIMEOUT) { + StockMessage("WES v2: Timeout waiting for response from " + host); + clientESP_RMS.stop(); + delay(100); + return; + } + } + + // Lecture de la première ligne (status HTTP) + timeout = millis(); + String statusLine = clientESP_RMS.readStringUntil('\n'); + + // Validation du code de statut HTTP + if (statusLine.indexOf("200") < 0) { + if (statusLine.indexOf("401") >= 0) { + StockMessage("WES v2: Authentication failed - Check username and password"); + } else if (statusLine.indexOf("404") >= 0) { + StockMessage("WES v2: File not found (404) - Check WES configuration"); + } else { + StockMessage("WES v2: HTTP error - " + statusLine); + } + clientESP_RMS.stop(); + return; + } + + // Lecture de la réponse HTTP + timeout = millis(); + bool headersEnded = false; + while (clientESP_RMS.available() && (millis() - timeout < WES_READ_TIMEOUT)) { + String line = clientESP_RMS.readStringUntil('\n'); + + // Détection de la fin des headers HTTP (ligne vide) + if (!headersEnded) { + if (line == "\r" || line == "") { + headersEnded = true; + } + continue; + } + + // Lecture du corps XML + Wes_Data += line; + } + clientESP_RMS.stop(); + + // Validation du numéro de pince + byte pinceNum = WesPinceNum; + if (pinceNum < 1 || pinceNum > 8) pinceNum = 1; // Validation: pince 1-8 + + // Stockage des données brutes pour debug + Wes_dataBrute = "WES v2 - Pince " + String(pinceNum) + + "
" + Wes_Data; + + // Extraction des données de la pince sélectionnée + String pinceTag = String(pinceNum); + + // On ne garde que la partie ... pour éviter de lire les de la partie + int pStart = Wes_Data.indexOf(""); + if (pStart > 0) { + Wes_Data = Wes_Data.substring(pStart); + } + + // Lecture de la tension (commune à toutes les pinces) + voltage = ValXml("V", Wes_Data); + + // Validation de la tension pour éviter les trous de valeurs (0V si parsing échoué) + if (voltage < 90.0) { + return; + } + + // Lecture des données spécifiques à la pince + current = ValXml("I" + pinceTag, Wes_Data); + cosPhi = ValXml("COSPHI" + pinceTag, Wes_Data); + energyIndex = ValXml("INDEX" + pinceTag, Wes_Data); + energyInject = ValXml("IDXINJECT" + pinceTag, Wes_Data); + + // Calcul de la puissance active : P = V * I * |cos(phi)| + // Le signe de COSPHI indique le sens : + // - COSPHI positif = consommation (soutirage) + // - COSPHI négatif = production (injection) + float Pva = voltage * current; // Puissance apparente S = V * I + float PwAbs = Pva * abs(cosPhi); // Puissance active P = S * |cos(phi)| + + // Mise à jour des variables globales selon le signe de COSPHI + if (cosPhi >= 0) { + // COSPHI positif = Consommation + PuissanceS_M_inst = PwAbs; + PuissanceI_M_inst = 0; + PVAS_M_inst = PfloatMax(Pva); + PVAI_M_inst = 0; + } else { + // COSPHI négatif = Production (injection) + PuissanceS_M_inst = 0; + PuissanceI_M_inst = PwAbs; + PVAI_M_inst = PfloatMax(Pva); + PVAS_M_inst = 0; + } + + // Mise à jour des autres paramètres + Tension_M = voltage; + Intensite_M = current; + PowerFactor_M = abs(cosPhi); + + // Énergie cumulée (INDEX et IDXINJECT sont en kWh, on convertit en Wh) + // INDEX = énergie soutirée (consommée) + // IDXINJECT = énergie injectée (produite) + energyIndex = ValXml("INDEX" + pinceTag, Wes_Data); + energyInject = ValXml("IDXINJECT" + pinceTag, Wes_Data); + + Energie_M_Soutiree = int(energyIndex * 1000); + Energie_M_Injectee = int(energyInject * 1000); + + // Validation des données + Pva_valide = true; + + // Filtrage de la puissance (fonction existante du routeur) + filtre_puissance(); + + // Reset du Watchdog + PuissanceRecue = true; + EnergieActiveValide = true; + + // Reset LED jaune (indicateur de communication) + if (cptLEDyellow > 30) { + cptLEDyellow = 4; + } +} diff --git a/Stockage.ino b/Stockage.ino index 2ff0ac5..b7e607d 100644 --- a/Stockage.ino +++ b/Stockage.ino @@ -678,6 +678,9 @@ void DeserializeConfiguration(String json) { EnphaseUser = conf["EnphaseUser"].as(); EnphasePwd = conf["EnphasePwd"].as(); EnphaseSerial = conf["EnphaseSerial"].as(); + WesUser = conf["WesUser"] | WesUser; + WesPwd = conf["WesPwd"] | WesPwd; + WesPinceNum = conf["WesPinceNum"] | WesPinceNum; MQTTRepet = conf["MQTTRepet"]; MQTTIP = conf["MQTTIP"]; MQTTPort = conf["MQTTPort"]; @@ -808,6 +811,9 @@ String SerializeConfiguration() { conf["EnphaseUser"] = EnphaseUser; conf["EnphasePwd"] = EnphasePwd; conf["EnphaseSerial"] = EnphaseSerial; + conf["WesUser"] = WesUser; + conf["WesPwd"] = WesPwd; + conf["WesPinceNum"] = WesPinceNum; if (ModePara == 0) { MQTTRepet = 0; subMQTT = 0;