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 = "" + nom + ">";
+ 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;