From 14b39675905262b745e59b3ece3c8402e870c871 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Thu, 14 May 2026 07:13:05 +0200 Subject: [PATCH] Ajout publication MQTT (PubSubClient) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Topics individuels par capteur sous un topic de base configurable (défaut: solar/) PV, batterie (tension/SOC/temp/statut), load, énergie, soleil, RS485, relais, entrées DI - Abonnement relay/1/set et relay/2/set pour piloter les relais depuis MQTT - Config NVS : serveur, port, user/pass optionnel, topic base, intervalle (défaut 30s) - Reconnexion automatique toutes les 15s si broker inaccessible - Publication immédiate après connexion et après changement de config - Route GET/POST /api/mqtt + UI onglet Config avec liste des topics générée dynamiquement - Stubs QEMU (#ifndef QEMU_BUILD) Co-Authored-By: Claude Sonnet 4.6 --- data/app.js | 83 +++++++++++++- data/index.html | 51 +++++++++ include/mqtt_client.h | 10 ++ platformio.ini | 2 + src/main.cpp | 3 + src/mqtt_client.cpp | 249 ++++++++++++++++++++++++++++++++++++++++++ src/webserver.cpp | 25 +++++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 include/mqtt_client.h create mode 100644 src/mqtt_client.cpp diff --git a/data/app.js b/data/app.js index a1a7648..8e7d990 100644 --- a/data/app.js +++ b/data/app.js @@ -51,7 +51,7 @@ function afficherOnglet(nom, bouton) { document.getElementById(nom).classList.add('actif'); bouton.classList.add('active'); if (nom === 'regles') chargerRegles(); - if (nom === 'config') { chargerSleep(); chargerWifi(); chargerPrefsUI(); chargerModbus(); chargerWireGuard(); } + if (nom === 'config') { chargerSleep(); chargerWifi(); chargerPrefsUI(); chargerModbus(); chargerMqtt(); chargerWireGuard(); } if (nom === 'historique') chargerHistorique(); if (nom === 'debug') chargerDebug(); if (nom === 'epever-config') lireConfigEpever(); @@ -848,6 +848,87 @@ function fermerSunPopup() { if (modal) modal.classList.add('hidden'); } +// --- MQTT --- + +function mqttAfficherTopics(base) { + const info = document.getElementById('mqtt-topics-info'); + const pub = document.getElementById('mqtt-topics-list'); + const cmd = document.getElementById('mqtt-cmd-list'); + if (!info || !pub || !cmd) return; + const b = base || 'solar'; + const pubTopics = [ + 'pv/voltage', 'pv/current', + 'battery/voltage', 'battery/soc', 'battery/temperature', 'battery/status', + 'load/voltage', 'load/current', 'load/power', + 'energy/generated/today', 'energy/generated/total', + 'energy/consumed/today', 'energy/consumed/total', + 'sun', 'rs485/ok', 'relay/1', 'relay/2', 'input/1', 'input/2' + ]; + pub.innerHTML = pubTopics.map(t => `${b}/${t}`).join('  '); + cmd.innerHTML = `${b}/relay/1/set  ${b}/relay/2/set` + + `
Valeurs acceptées : ON / OFF`; + info.classList.remove('hidden'); +} + +async function chargerMqtt() { + try { + const d = await (await fetch('/api/mqtt')).json(); + const bar = document.getElementById('mqtt-status-bar'); + if (bar) { + if (d.enabled && d.connected) { + bar.textContent = '✓ Connecté — ' + d.server + ':' + d.port; + bar.className = 'ec-statusbar ec-ok'; + } else if (d.enabled) { + bar.textContent = '⏳ Activé — en attente de connexion WiFi ou broker'; + bar.className = 'ec-statusbar'; + } else { + bar.textContent = 'Désactivé'; + bar.className = 'ec-statusbar'; + } + } + const s = id => document.getElementById(id); + s('mqtt-enabled').value = String(!!d.enabled); + if (s('mqtt-server')) s('mqtt-server').value = d.server || '192.168.1.36'; + if (s('mqtt-port')) s('mqtt-port').value = d.port || 1883; + if (s('mqtt-user')) s('mqtt-user').value = d.user || ''; + if (s('mqtt-pass')) s('mqtt-pass').value = d.pass || ''; + if (s('mqtt-base')) s('mqtt-base').value = d.base || 'solar'; + if (s('mqtt-interval')) s('mqtt-interval').value = d.interval || 30; + mqttAfficherTopics(d.base || 'solar'); + } catch { /* silencieux */ } +} + +async function sauvegarderMqtt() { + const g = id => (document.getElementById(id)?.value || '').trim(); + const enabled = g('mqtt-enabled') === 'true'; + const server = g('mqtt-server') || '192.168.1.36'; + const port = parseInt(g('mqtt-port')) || 1883; + const user = g('mqtt-user'); + const pass = g('mqtt-pass'); + const base = g('mqtt-base') || 'solar'; + const interval = parseInt(g('mqtt-interval')) || 30; + + if (enabled && !server) { afficherToast('⚠ Adresse serveur requise'); return; } + + try { + const res = await fetch('/api/mqtt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled, server, port, user, pass, base, interval }) + }); + const d = await res.json(); + if (d.ok) { + afficherToast(enabled ? '✓ MQTT activé — connexion en cours' : '✓ MQTT désactivé'); + mqttAfficherTopics(base); + setTimeout(chargerMqtt, 3000); + } else { + afficherToast('⚠ Erreur sauvegarde MQTT'); + } + } catch(e) { + afficherToast('Erreur : ' + e.message); + } +} + // --- WireGuard --- async function chargerWireGuard() { diff --git a/data/index.html b/data/index.html index 942f6ae..1163f50 100644 --- a/data/index.html +++ b/data/index.html @@ -425,6 +425,57 @@ +
+
Publication MQTT
+

Envoie les données des capteurs vers un broker MQTT (Home Assistant, Mosquitto…). Les relais sont pilotables via les topics de commande.

+ +
Chargement…
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + s +
+
+ + + + +
+
VPN WireGuard

Tunnel chiffré vers votre serveur WireGuard. Nécessite une connexion WiFi (mode STA). Désactivé par défaut — la configuration locale reste accessible même si le VPN est coupé.

diff --git a/include/mqtt_client.h b/include/mqtt_client.h new file mode 100644 index 0000000..3a1e79c --- /dev/null +++ b/include/mqtt_client.h @@ -0,0 +1,10 @@ +#pragma once +#include + +void initMqtt(); +void gererMqtt(); +void getMqttJson(String &out); +bool setMqttConfig(bool enabled, const char* server, uint16_t port, + const char* user, const char* pass, + const char* topicBase, uint32_t intervalMs); +void mqttPublierEtat(); // publication immédiate (ex: après changement relais) diff --git a/platformio.ini b/platformio.ini index e7705ad..2fd97ba 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,6 +13,7 @@ lib_deps = https://github.com/me-no-dev/ESPAsyncWebServer.git ayushsharma82/ElegantOTA @ ^3.1.0 emelianov/modbus-esp8266 @ ^4.1.0 + knolleary/PubSubClient @ ^2.8 ; --- Cible physique KC868-A2 --- [env:kc868_a2] @@ -21,6 +22,7 @@ build_flags = -D ELEGANTOTA_USE_ASYNC_WEBSERVER=1 lib_deps = ${common.lib_deps} https://github.com/ciniml/WireGuard-ESP32-Arduino.git + knolleary/PubSubClient @ ^2.8 ; --- Build QEMU : WiFi/sleep désactivés, Modbus + règles actifs --- ; Compiler : pio run -e qemu diff --git a/src/main.cpp b/src/main.cpp index 259015b..f9b2f57 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include "debug_log.h" #include "epever_config.h" #include "wireguard_vpn.h" +#include "mqtt_client.h" // Instance globale partagée entre tous les modules SystemState state; @@ -43,6 +44,7 @@ void setup() { initHistorique(); initConfigEpever(); initWireGuard(); + initMqtt(); debugLogf("Système prêt."); } @@ -50,6 +52,7 @@ void setup() { void loop() { gererWifi(); gererWireGuard(); + gererMqtt(); gererOTA(); gererBoutons(); gererModbus(); diff --git a/src/mqtt_client.cpp b/src/mqtt_client.cpp new file mode 100644 index 0000000..81e9936 --- /dev/null +++ b/src/mqtt_client.cpp @@ -0,0 +1,249 @@ +#include "mqtt_client.h" + +#ifndef QEMU_BUILD + +#include +#include +#include +#include +#include "state.h" +#include "config.h" + +// --- Config --- +static bool mqttActif = false; +static String mqttServer = "192.168.1.36"; +static uint16_t mqttPort = 1883; +static String mqttUser; +static String mqttPass; +static String mqttBase = "solar"; // topic de base +static uint32_t mqttInterval = 30000; // ms entre publications + +// --- État runtime --- +static WiFiClient wifiClient; +static PubSubClient mqttClient(wifiClient); +static bool mqttConnecte = false; +static unsigned long tDernierePubli = 0; +static unsigned long tDerniereConnex = 0; +static const unsigned long RECONNECT_INTERVAL = 15000UL; + +// --------------------------------------------------------------------------- +// NVS +// --------------------------------------------------------------------------- + +static void chargerNVS() { + Preferences p; + p.begin("mqtt", true); + mqttActif = p.getBool("enabled", false); + mqttServer = p.getString("server", "192.168.1.36"); + mqttPort = p.getUShort("port", 1883); + mqttUser = p.getString("user", ""); + mqttPass = p.getString("pass", ""); + mqttBase = p.getString("base", "solar"); + mqttInterval = p.getULong("interval", 30000); + p.end(); +} + +// --------------------------------------------------------------------------- +// Topics dérivés du topic de base +// --------------------------------------------------------------------------- + +static String t(const char* suffixe) { return mqttBase + "/" + suffixe; } + +// --------------------------------------------------------------------------- +// Callback réception (commande relais / entrées) +// --------------------------------------------------------------------------- + +static void mqttCallback(char* topic, byte* payload, unsigned int len) { + String msg; + for (unsigned int i = 0; i < len; i++) msg += (char)payload[i]; + msg.trim(); + + bool etat = (msg == "ON" || msg == "1" || msg == "true"); + + String top = String(topic); + if (top == t("relay/1/set")) { + state.relay1 = etat; + digitalWrite(PIN_RELAY1, etat ? HIGH : LOW); + Serial.printf("[MQTT] Relais 1 → %s\n", etat ? "ON" : "OFF"); + } else if (top == t("relay/2/set")) { + state.relay2 = etat; + digitalWrite(PIN_RELAY2, etat ? HIGH : LOW); + Serial.printf("[MQTT] Relais 2 → %s\n", etat ? "ON" : "OFF"); + } +} + +// --------------------------------------------------------------------------- +// Connexion / reconnexion +// --------------------------------------------------------------------------- + +static void connecterMqtt() { + if (WiFi.status() != WL_CONNECTED) return; + + mqttClient.setServer(mqttServer.c_str(), mqttPort); + mqttClient.setCallback(mqttCallback); + mqttClient.setBufferSize(512); + + String clientId = "kc868-" + WiFi.macAddress(); + clientId.replace(":", ""); + + bool ok; + if (mqttUser.length() > 0) { + ok = mqttClient.connect(clientId.c_str(), mqttUser.c_str(), mqttPass.c_str()); + } else { + ok = mqttClient.connect(clientId.c_str()); + } + + if (ok) { + mqttConnecte = true; + Serial.printf("[MQTT] Connecté — %s:%u base: %s intervalle: %us\n", + mqttServer.c_str(), mqttPort, + mqttBase.c_str(), mqttInterval / 1000); + // Abonnements commandes + mqttClient.subscribe(t("relay/1/set").c_str()); + mqttClient.subscribe(t("relay/2/set").c_str()); + // Publication immédiate après connexion + mqttPublierEtat(); + } else { + mqttConnecte = false; + Serial.printf("[MQTT] Échec connexion (rc=%d) — retry dans %lus\n", + mqttClient.state(), RECONNECT_INTERVAL / 1000); + } +} + +// --------------------------------------------------------------------------- +// Publication état complet +// --------------------------------------------------------------------------- + +static void pub(const char* suffixe, float val, int decimales = 2) { + char buf[16]; + dtostrf(val, 1, decimales, buf); + mqttClient.publish(t(suffixe).c_str(), buf, true); +} + +static void pub(const char* suffixe, bool val) { + mqttClient.publish(t(suffixe).c_str(), val ? "ON" : "OFF", true); +} + +static void pub(const char* suffixe, int val) { + mqttClient.publish(t(suffixe).c_str(), String(val).c_str(), true); +} + +void mqttPublierEtat() { + if (!mqttConnecte || !mqttClient.connected()) return; + + // Panneau solaire + pub("pv/voltage", state.pv); + pub("pv/current", state.pvCurrent); + + // Batterie + pub("battery/voltage", state.battery); + pub("battery/soc", (int)state.batSOC); + pub("battery/temperature", state.batTemperature, 1); + pub("battery/status", (int)state.batStatut); + + // Sortie de charge + pub("load/voltage", state.loadVoltage); + pub("load/current", state.loadCurrent); + pub("load/power", state.loadPower, 1); + + // Énergie + pub("energy/generated/today", state.energieGenJour); + pub("energy/generated/total", state.energieGenTotal); + pub("energy/consumed/today", state.energieConJour); + pub("energy/consumed/total", state.energieConTotal); + + // État général + pub("sun", state.sun); + pub("rs485/ok", state.rs485_ok); + + // Relais + pub("relay/1", state.relay1); + pub("relay/2", state.relay2); + + // Entrées numériques + pub("input/1", state.di1); + pub("input/2", state.di2); + + tDernierePubli = millis(); +} + +// --------------------------------------------------------------------------- +// API publique +// --------------------------------------------------------------------------- + +void initMqtt() { + chargerNVS(); + Serial.printf("[MQTT] %s — %s:%u base: %s intervalle: %us\n", + mqttActif ? "Activé" : "Désactivé", + mqttServer.c_str(), mqttPort, + mqttBase.c_str(), mqttInterval / 1000); +} + +void gererMqtt() { + if (!mqttActif) return; + if (WiFi.status() != WL_CONNECTED) { + if (mqttConnecte) { mqttConnecte = false; } + return; + } + + if (!mqttClient.connected()) { + mqttConnecte = false; + unsigned long maintenant = millis(); + if ((maintenant - tDerniereConnex) >= RECONNECT_INTERVAL) { + tDerniereConnex = maintenant; + connecterMqtt(); + } + return; + } + + mqttClient.loop(); + + // Publication périodique + if ((millis() - tDernierePubli) >= mqttInterval) { + mqttPublierEtat(); + } +} + +bool setMqttConfig(bool enabled, const char* server, uint16_t port, + const char* user, const char* pass, + const char* topicBase, uint32_t intervalMs) { + Preferences p; + p.begin("mqtt", false); + p.putBool("enabled", enabled); + p.putString("server", server); + p.putUShort("port", port); + p.putString("user", user); + p.putString("pass", pass); + p.putString("base", topicBase); + p.putULong("interval", intervalMs); + p.end(); + + if (mqttConnecte) { mqttClient.disconnect(); mqttConnecte = false; } + tDerniereConnex = 0; + chargerNVS(); + Serial.printf("[MQTT] Config mise à jour — %s\n", mqttActif ? "activé" : "désactivé"); + return true; +} + +void getMqttJson(String &out) { + JsonDocument doc; + doc["enabled"] = mqttActif; + doc["connected"] = mqttConnecte; + doc["server"] = mqttServer; + doc["port"] = mqttPort; + doc["user"] = mqttUser; + doc["pass"] = mqttPass; + doc["base"] = mqttBase; + doc["interval"] = mqttInterval / 1000; // en secondes pour l'UI + serializeJson(doc, out); +} + +#else +// --- Stubs QEMU --- +void initMqtt() { Serial.println("[MQTT] Désactivé (build QEMU)"); } +void gererMqtt() {} +void mqttPublierEtat() {} +void getMqttJson(String &out) { out = "{\"enabled\":false,\"connected\":false}"; } +bool setMqttConfig(bool, const char*, uint16_t, const char*, const char*, + const char*, uint32_t) { return false; } +#endif diff --git a/src/webserver.cpp b/src/webserver.cpp index 3a0d351..fbea4d6 100644 --- a/src/webserver.cpp +++ b/src/webserver.cpp @@ -14,6 +14,7 @@ #include "epever_config.h" #include "wifi_ap.h" #include "wireguard_vpn.h" +#include "mqtt_client.h" #include "debug_log.h" AsyncWebServer server(80); @@ -440,6 +441,30 @@ void demarrerWebserveur() { }); server.addHandler(handlerMdns); + // --- API MQTT --- + + server.on("/api/mqtt", HTTP_GET, [](AsyncWebServerRequest *r) { + String json; + getMqttJson(json); + r->send(200, "application/json", json); + }); + + auto *handlerMqtt = new AsyncCallbackJsonWebHandler("/api/mqtt", + [](AsyncWebServerRequest *r, JsonVariant &json) { + JsonObject obj = json.as(); + bool enabled = obj["enabled"] | false; + const char* srv = obj["server"] | "192.168.1.36"; + uint16_t port = obj["port"] | 1883u; + const char* user = obj["user"] | ""; + const char* pass = obj["pass"] | ""; + const char* base = obj["base"] | "solar"; + uint32_t interval = (uint32_t)((obj["interval"] | 30u)) * 1000u; + interval = constrain(interval, 5000u, 3600000u); + bool ok = setMqttConfig(enabled, srv, port, user, pass, base, interval); + r->send(ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}"); + }); + server.addHandler(handlerMqtt); + // --- API WireGuard --- server.on("/api/wireguard", HTTP_GET, [](AsyncWebServerRequest *r) {