Initial commit — KC868-A2 contrôleur solaire ESP32

Fonctionnalités :
- Lecture RS485 Modbus Epever Tracer 4210N (115200 bps, FC03/FC04/FC16)
- Moteur de règles JSON (LittleFS) — commande automatique des relais
- Interface web mobile-first (dashboard, règles, config, historique, EPEVER, debug)
- WiFi AP+STA simultanés avec reconnexion automatique et portail captif
- mDNS configurable (pv.local par défaut)
- Configuration registres EPEVER depuis l'UI (18 registres holding)
- Historique basse/haute résolution avec graphes canvas
- VPN WireGuard optionnel (désactivé par défaut, config via UI)
- OTA firmware + filesystem via ElegantOTA
- Deep sleep / économie d'énergie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 19:25:01 +02:00
commit a8f0d6ccba
88 changed files with 13162 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
#include <Arduino.h>
#include "config.h"
#include "state.h"
// Suivi anti-rebond pour un bouton
struct Bouton {
uint8_t pin;
bool lectureRaw; // dernière lecture brute
bool etatConfirme; // état validé après anti-rebond
unsigned long tChangement;
};
static Bouton di1 = { PIN_DI1, HIGH, HIGH, 0 };
static Bouton di2 = { PIN_DI2, HIGH, HIGH, 0 };
// Retourne true une seule fois au moment où l'appui est confirmé (front descendant)
static bool detecterAppui(Bouton &b) {
bool lecture = digitalRead(b.pin);
if (lecture != b.lectureRaw) {
b.lectureRaw = lecture;
b.tChangement = millis();
}
if ((millis() - b.tChangement) >= DEBOUNCE_BOUTON && lecture != b.etatConfirme) {
b.etatConfirme = lecture;
return (b.etatConfirme == LOW); // LOW = contact fermé = appui
}
return false;
}
void initBoutons() {
// GPIO36 et GPIO39 sont input-only — pas de pull-up interne possible sur ESP32
pinMode(PIN_DI1, INPUT);
pinMode(PIN_DI2, INPUT);
Serial.println("Boutons DI1/DI2 initialisés");
}
void gererBoutons() {
bool appui1 = detecterAppui(di1);
bool appui2 = detecterAppui(di2);
if (appui1) Serial.println("[DI] DI1 appui détecté");
if (appui2) Serial.println("[DI] DI2 appui détecté");
// Mise à jour état pour l'interface web et les règles
state.di1 = (di1.etatConfirme == LOW);
state.di2 = (di2.etatConfirme == LOW);
}
+79
View File
@@ -0,0 +1,79 @@
#include <Arduino.h>
#include <stdarg.h>
#include "debug_log.h"
static const uint8_t NB_LIGNES = 80;
static const uint8_t TAILLE_LIGNE = 160;
static char lignes[NB_LIGNES][TAILLE_LIGNE];
static uint32_t horodatage[NB_LIGNES];
static uint8_t prochaineLigne = 0;
static uint8_t nbLignes = 0;
static uint32_t compteur = 0;
static void appendJsonString(String &out, const char *s) {
out += '"';
while (*s) {
char c = *s++;
switch (c) {
case '\\': out += "\\\\"; break;
case '"': out += "\\\""; break;
case '\n': out += "\\n"; break;
case '\r': break;
case '\t': out += "\\t"; break;
default:
if ((uint8_t)c < 0x20) out += ' ';
else out += c;
break;
}
}
out += '"';
}
void debugLogLine(const char *message) {
if (!message) return;
snprintf(lignes[prochaineLigne], TAILLE_LIGNE, "%s", message);
horodatage[prochaineLigne] = millis();
prochaineLigne = (prochaineLigne + 1) % NB_LIGNES;
if (nbLignes < NB_LIGNES) nbLignes++;
compteur++;
}
void debugLogf(const char *format, ...) {
char buffer[TAILLE_LIGNE];
va_list args;
va_start(args, format);
vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
Serial.println(buffer);
debugLogLine(buffer);
}
void getDebugLogJson(String &out) {
out.reserve(NB_LIGNES * 96);
out = "{\"count\":";
out += compteur;
out += ",\"lines\":[";
for (uint8_t i = 0; i < nbLignes; i++) {
uint8_t idx = (prochaineLigne + NB_LIGNES - nbLignes + i) % NB_LIGNES;
if (i) out += ',';
out += "{\"t\":";
out += horodatage[idx];
out += ",\"m\":";
appendJsonString(out, lignes[idx]);
out += '}';
}
out += "]}";
}
void clearDebugLog() {
prochaineLigne = 0;
nbLignes = 0;
compteur = 0;
debugLogf("[DEBUG] Journal vidé");
}
+333
View File
@@ -0,0 +1,333 @@
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "epever_config.h"
#include "modbus_epever.h"
#include "debug_log.h"
#define FICHIER_CONFIG "/epever_config.json"
// ---------------------------------------------------------------------------
// Table des registres de configuration
// ---------------------------------------------------------------------------
const EpeverRegDef EPEVER_REGS[] = {
// reg key scale min max wr unit label aide
{ 0x9000, "battery_type", 1.0f, 0, 4, true, "", "Type batterie", "0=Perso, 1=Scellée (AGM), 2=GEL, 3=Liquide, 4=Lithium" },
{ 0x9001, "battery_capacity", 1.0f, 1, 9999, true, "Ah", "Capacité", "Capacité nominale en Ah. Influence les seuils de protection." },
{ 0x9002, "temp_compensation", 1.0f, -9, 0, true, "mV/°C/2V", "Compensation température", "Ajuste la tension de charge selon la température. Typique : -3 pour plomb-acide. Laisser 0 si inconnu." },
{ 0x9003, "high_volt_disconnect", 0.01f, 13.0f, 17.0f, true, "V", "Déconnexion survoltage", "Tension à laquelle le régulateur coupe pour protéger la batterie contre la surcharge." },
{ 0x9004, "charging_limit", 0.01f, 13.0f, 16.0f, true, "V", "Limite de charge", "Tension maximale autorisée pendant la charge." },
{ 0x9005, "overvolt_reconnect", 0.01f, 13.0f, 16.0f, true, "V", "Reconnexion survoltage", "En dessous de cette tension, la charge reprend après une coupure survoltage." },
{ 0x9006, "equalize_voltage", 0.01f, 13.0f, 16.0f, true, "V", "Tension égalisation", "Tension appliquée lors des cycles d'égalisation (désulfatation). Typique : 14.6V." },
{ 0x9007, "boost_voltage", 0.01f, 13.0f, 15.5f, true, "V", "Tension boost", "Tension d'absorption (charge rapide). Typique : 14.4V pour plomb-acide." },
{ 0x9008, "float_voltage", 0.01f, 13.0f, 14.5f, true, "V", "Tension float", "Tension de maintien après charge complète. Typique : 13.6V. Trop haute = usure prématurée." },
{ 0x9009, "boost_reconnect", 0.01f, 11.0f, 14.0f, true, "V", "Reconnexion boost", "Si la tension tombe sous ce seuil, la charge boost reprend automatiquement." },
{ 0x900A, "low_reconnect", 0.01f, 10.0f, 13.5f, true, "V", "Reconnexion basse tension", "Tension à laquelle la sortie de charge est réactivée après une coupure." },
{ 0x900B, "undervolt_warning", 0.01f, 10.0f, 13.0f, true, "V", "Alerte basse tension", "Déclenche une alarme sans couper. Prévenez avant que la batterie soit trop déchargée." },
{ 0x900C, "low_disconnect", 0.01f, 9.0f, 13.0f, true, "V", "Déconnexion basse tension", "La sortie charge est coupée sous cette tension pour protéger la batterie." },
{ 0x900D, "discharge_limit", 0.01f, 9.0f, 13.0f, true, "V", "Limite de décharge", "Tension minimale absolue. Ne jamais descendre en dessous." },
{ 0x906B, "rated_voltage", 1.0f, 0, 2, true, "", "Tension nominale système", "0=Auto détecté, 1=Système 12V, 2=Système 24V. Laisser Auto si possible." },
{ 0x906C, "equalize_duration", 1.0f, 0, 180, true, "min", "Durée égalisation", "Durée du cycle d'égalisation en minutes (0=désactivé)." },
{ 0x906D, "boost_duration", 1.0f, 10, 180, true, "min", "Durée boost", "Durée maximale de la phase d'absorption en minutes." },
{ 0x906E, "bat_discharge_soc", 1.0f, 20, 100, true, "%", "SOC déconnexion", "Seuil de charge minimum (%) avant coupure de la sortie." },
};
const uint8_t EPEVER_REGS_COUNT = sizeof(EPEVER_REGS) / sizeof(EPEVER_REGS[0]);
// ---------------------------------------------------------------------------
// Cache en RAM
// ---------------------------------------------------------------------------
static float configCache[18];
static bool configValide = false;
static unsigned long tDerniereSync = 0;
static String derniereErreur;
// ---------------------------------------------------------------------------
// Helpers internes
// ---------------------------------------------------------------------------
static int8_t indexPourReg(uint16_t reg) {
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
if (EPEVER_REGS[i].reg == reg) return (int8_t)i;
}
return -1;
}
static bool estBloc1(uint16_t reg) { return reg >= 0x9000u && reg <= 0x900Du; }
static bool estBloc2(uint16_t reg) { return reg >= 0x906Bu && reg <= 0x906Eu; }
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
void initConfigEpever() {
debugLogf("[Config] initConfigEpever — %u registres", EPEVER_REGS_COUNT);
}
// ---------------------------------------------------------------------------
// Lecture depuis l'Epever
// ---------------------------------------------------------------------------
bool lireConfigEpever() {
if (!modbusAcquire()) {
derniereErreur = "Modbus occupé, réessayer dans quelques secondes";
debugLogf("[Config] Lecture refusée: %s", derniereErreur.c_str());
return false;
}
uint16_t raw1[EPEVER_BLOC1_QTY];
uint16_t raw2[EPEVER_BLOC2_QTY];
bool ok1 = modbusLireHolding(EPEVER_BLOC1_REG, EPEVER_BLOC1_QTY, raw1, 900);
if (!ok1) {
derniereErreur = "Erreur RS485 lecture bloc 0x9000 (tensions)";
debugLogf("[Config] %s", derniereErreur.c_str());
modbusRelease();
return false;
}
bool ok2 = modbusLireHolding(EPEVER_BLOC2_REG, EPEVER_BLOC2_QTY, raw2, 700);
if (!ok2) {
// Bloc 2 non fatal : on garde les valeurs du cache ou zéro
debugLogf("[Config] Bloc 0x906B indisponible — valeurs conservées");
}
modbusRelease();
// Décoder bloc 1 (registres 0x9000 → 0x900D)
for (uint8_t i = 0; i < EPEVER_BLOC1_QTY; i++) {
int8_t idx = indexPourReg(0x9000u + i);
if (idx < 0) continue;
const EpeverRegDef &d = EPEVER_REGS[idx];
if (d.scale == 1.0f && d.valMin < 0) {
// valeur signée (ex: temp_compensation)
configCache[idx] = (int16_t)raw1[i] * d.scale;
} else {
configCache[idx] = raw1[i] * d.scale;
}
}
// Décoder bloc 2 (registres 0x906B → 0x906E)
if (ok2) {
for (uint8_t i = 0; i < EPEVER_BLOC2_QTY; i++) {
int8_t idx = indexPourReg(0x906Bu + i);
if (idx < 0) continue;
configCache[idx] = raw2[i] * EPEVER_REGS[idx].scale;
}
}
configValide = true;
tDerniereSync = millis();
derniereErreur = "";
debugLogf("[Config] Lecture OK — Type:%d Capa:%dAh Float:%.2fV Boost:%.2fV",
(int)configCache[indexPourReg(0x9000)],
(int)configCache[indexPourReg(0x9001)],
configCache[indexPourReg(0x9008)],
configCache[indexPourReg(0x9007)]);
return true;
}
// ---------------------------------------------------------------------------
// Écriture vers l'Epever
// ---------------------------------------------------------------------------
bool ecrireConfigEpever(JsonObject obj, String &erreur) {
if (!configValide) {
erreur = "Lecture préalable requise — cliquer Lire d'abord";
return false;
}
if (!modbusAcquire()) {
erreur = "Modbus occupé, réessayer dans quelques secondes";
return false;
}
// Validation complète avant d'écrire quoi que ce soit
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (!obj[d.key].is<float>() && !obj[d.key].is<int>()) continue;
float val = obj[d.key].as<float>();
if (val < d.valMin || val > d.valMax) {
erreur = String(d.label) + " hors plage [" + String(d.valMin, 2) + "" + String(d.valMax, 2) + "]";
modbusRelease();
return false;
}
}
// Vérification cohérence tensions clés
auto getVal = [&](const char *key) -> float {
if (obj[key].is<float>() || obj[key].is<int>()) return obj[key].as<float>();
int8_t idx = indexPourReg(0); // recalculé
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
if (strcmp(EPEVER_REGS[i].key, key) == 0) return configCache[i];
}
return 0.0f;
};
float floatV = getVal("float_voltage");
float boostV = getVal("boost_voltage");
float eqV = getVal("equalize_voltage");
float discoV = getVal("low_disconnect");
float reconV = getVal("low_reconnect");
if (floatV >= boostV) {
erreur = "Float Voltage doit être < Boost Voltage";
modbusRelease();
return false;
}
if (discoV >= reconV) {
erreur = "Déconnexion basse tension doit être < Reconnexion";
modbusRelease();
return false;
}
if (boostV > eqV && eqV > 0) {
erreur = "Boost Voltage doit être ≤ Equalize Voltage";
modbusRelease();
return false;
}
// Écriture registre par registre
bool ok = true;
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (!d.writable) continue;
if (!obj[d.key].is<float>() && !obj[d.key].is<int>()) continue;
float val = obj[d.key].as<float>();
uint16_t raw;
if (d.scale == 1.0f && d.valMin < 0) {
raw = (uint16_t)(int16_t)round(val); // signé (temp_compensation)
} else if (d.scale < 1.0f) {
raw = (uint16_t)round(val / d.scale); // tension ÷ 0.01
} else {
raw = (uint16_t)round(val); // entier brut
}
if (!modbusEcrireHolding(d.reg, 1, &raw, 800)) {
erreur = String("Erreur RS485 écriture ") + d.label;
ok = false;
break;
}
configCache[i] = val;
delay(15);
}
modbusRelease();
if (ok) {
derniereErreur = "";
debugLogf("[Config] Écriture OK");
} else {
derniereErreur = erreur;
debugLogf("[Config] Écriture ERREUR: %s", erreur.c_str());
}
return ok;
}
// ---------------------------------------------------------------------------
// Persistance LittleFS
// ---------------------------------------------------------------------------
bool sauvegarderConfigJson() {
if (!configValide) {
debugLogf("[Config] Sauvegarde refusée: cache non valide");
return false;
}
JsonDocument doc;
JsonObject values = doc["values"].to<JsonObject>();
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (d.scale < 1.0f) {
values[d.key] = roundf(configCache[i] * 100.0f) / 100.0f;
} else {
values[d.key] = (int)configCache[i];
}
}
File f = LittleFS.open(FICHIER_CONFIG, "w");
if (!f) {
debugLogf("[Config] Impossible d'ouvrir %s en écriture", FICHIER_CONFIG);
return false;
}
serializeJson(doc, f);
f.close();
debugLogf("[Config] Sauvegardé dans %s", FICHIER_CONFIG);
return true;
}
bool restaurerConfigJson(String &erreur) {
if (!LittleFS.exists(FICHIER_CONFIG)) {
erreur = "Aucune sauvegarde trouvée";
return false;
}
File f = LittleFS.open(FICHIER_CONFIG, "r");
if (!f) { erreur = "Impossible de lire la sauvegarde"; return false; }
JsonDocument doc;
DeserializationError e = deserializeJson(doc, f);
f.close();
if (e) { erreur = "JSON invalide dans la sauvegarde"; return false; }
JsonObject values = doc["values"].as<JsonObject>();
if (values.isNull()) { erreur = "Format sauvegarde invalide"; return false; }
return ecrireConfigEpever(values, erreur);
}
// ---------------------------------------------------------------------------
// Sérialisation JSON pour les API
// ---------------------------------------------------------------------------
void getConfigJson(String &out) {
JsonDocument doc;
doc["ok"] = configValide;
doc["valide"] = configValide;
doc["uptime"] = millis();
if (!derniereErreur.isEmpty()) doc["erreur"] = derniereErreur;
JsonObject values = doc["values"].to<JsonObject>();
if (configValide) {
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
if (d.scale < 1.0f) {
values[d.key] = roundf(configCache[i] * 100.0f) / 100.0f;
} else {
values[d.key] = (int)configCache[i];
}
}
}
// Schéma (labels + unités + aide + plages) pour génération UI
JsonArray schema = doc["schema"].to<JsonArray>();
for (uint8_t i = 0; i < EPEVER_REGS_COUNT; i++) {
const EpeverRegDef &d = EPEVER_REGS[i];
JsonObject r = schema.add<JsonObject>();
r["key"] = d.key;
r["label"] = d.label;
r["unit"] = d.unit;
r["aide"] = d.aide;
r["min"] = d.valMin;
r["max"] = d.valMax;
r["writable"] = d.writable;
r["scale"] = d.scale;
}
serializeJson(doc, out);
}
void getConfigSauvegardeJson(String &out) {
if (!LittleFS.exists(FICHIER_CONFIG)) {
out = "{\"ok\":false,\"erreur\":\"Aucune sauvegarde\"}";
return;
}
File f = LittleFS.open(FICHIER_CONFIG, "r");
if (!f) { out = "{\"ok\":false,\"erreur\":\"Lecture impossible\"}"; return; }
out = f.readString();
f.close();
}
+262
View File
@@ -0,0 +1,262 @@
#include "historique.h"
#include "state.h"
#include "debug_log.h"
#include <LittleFS.h>
// ─── Dimensions ───────────────────────────────────────────────────────────────
#define HIRES_MAX 240 // 4h × 60 min, une mesure/min
#define LORES_MAX 312 // 26h × 12 pts/h (5 min)
#define FICHIER_HIST "/hist.bin"
// ─── Format binaire du fichier (13 octets/entrée) ────────────────────────────
struct __attribute__((packed)) HistEntry {
float bat;
float pv;
float load;
uint8_t soc;
};
// ─── Buffers haute résolution (RAM uniquement) ───────────────────────────────
static float hrBat[HIRES_MAX], hrPV[HIRES_MAX], hrLoad[HIRES_MAX];
static uint8_t hrSOC[HIRES_MAX];
static uint16_t hrTete = 0, hrN = 0;
// ─── Buffers basse résolution (RAM + fichier) ────────────────────────────────
static float lrBat[LORES_MAX], lrPV[LORES_MAX], lrLoad[LORES_MAX];
static uint8_t lrSOC[LORES_MAX];
static uint16_t lrTete = 0, lrN = 0;
// ─── Accumulateurs pour la moyenne 5 min ─────────────────────────────────────
static float accBat = 0, accPV = 0, accLoad = 0;
static uint16_t accSOC = 0, accN = 0;
static uint8_t lrDepuisHr = 0; // pts lores ajoutés depuis la dernière sauvegarde
// ─── Timers ───────────────────────────────────────────────────────────────────
static unsigned long tDernMin = 0;
static unsigned long tDern5Min = 0;
static unsigned long tDernHr = 0;
static bool premierPointAjoute = false;
// ─── Index ordonné dans un ring buffer ───────────────────────────────────────
static inline uint16_t hrIdx(uint16_t i) {
return (uint16_t)((hrTete - hrN + i + HIRES_MAX) % HIRES_MAX);
}
static inline uint16_t lrIdx(uint16_t i) {
return (uint16_t)((lrTete - lrN + i + LORES_MAX) % LORES_MAX);
}
// ─── Ajout d'un point hires ──────────────────────────────────────────────────
static void pushHires() {
hrBat[hrTete] = state.battery;
hrPV[hrTete] = state.pv;
hrLoad[hrTete] = state.loadPower;
hrSOC[hrTete] = state.batSOC;
hrTete = (hrTete + 1) % HIRES_MAX;
if (hrN < HIRES_MAX) hrN++;
accBat += state.battery;
accPV += state.pv;
accLoad += state.loadPower;
accSOC += state.batSOC;
accN++;
debugLogf("[HIST] Point hires #%u — bat=%.2fV pv=%.2fV load=%.1fW soc=%u",
hrN, state.battery, state.pv, state.loadPower, state.batSOC);
}
// ─── Flush de la moyenne 5 min vers lores ────────────────────────────────────
static void pushLores() {
if (accN == 0) return;
lrBat[lrTete] = accBat / accN;
lrPV[lrTete] = accPV / accN;
lrLoad[lrTete] = accLoad / accN;
lrSOC[lrTete] = (uint8_t)(accSOC / accN);
lrTete = (lrTete + 1) % LORES_MAX;
if (lrN < LORES_MAX) lrN++;
lrDepuisHr++;
debugLogf("[HIST] Point lores #%u — moyennes sur %u pt(s): bat=%.2fV pv=%.2fV load=%.1fW soc=%u",
lrN, accN, lrBat[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrPV[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrLoad[(lrTete + LORES_MAX - 1) % LORES_MAX],
lrSOC[(lrTete + LORES_MAX - 1) % LORES_MAX]);
accBat = accPV = accLoad = 0;
accSOC = 0; accN = 0;
}
// ─── Sauvegarde horaire vers /hist.bin ───────────────────────────────────────
static void sauvegarderHeure() {
if (lrDepuisHr == 0) return;
// Lire les entrées existantes (max LORES_MAX)
HistEntry buf[LORES_MAX];
uint16_t existant = 0;
File fr = LittleFS.open(FICHIER_HIST, "r");
if (fr) {
existant = (uint16_t)min((size_t)(fr.size() / sizeof(HistEntry)),
(size_t)LORES_MAX);
fr.read((uint8_t*)buf, existant * sizeof(HistEntry));
fr.close();
}
// Ajouter les lrDepuisHr nouvelles entrées depuis le ring lores
uint16_t debut = (lrN >= lrDepuisHr) ? (lrN - lrDepuisHr) : 0;
for (uint16_t i = debut; i < lrN; i++) {
if (existant >= LORES_MAX) {
memmove(buf, buf + 1, (existant - 1) * sizeof(HistEntry));
existant--;
}
uint16_t idx = lrIdx(i);
buf[existant++] = { lrBat[idx], lrPV[idx], lrLoad[idx], lrSOC[idx] };
}
// Réécrire le fichier
File fw = LittleFS.open(FICHIER_HIST, "w");
if (fw) {
fw.write((uint8_t*)buf, existant * sizeof(HistEntry));
fw.close();
Serial.printf("[HIST] Sauvegarde %d pts → %d total (%dB)\n",
lrDepuisHr, existant,
(int)(existant * sizeof(HistEntry)));
} else {
Serial.println("[HIST] Erreur écriture hist.bin");
}
lrDepuisHr = 0;
}
// ─── Chargement du fichier au démarrage ──────────────────────────────────────
static void chargerFichier() {
File f = LittleFS.open(FICHIER_HIST, "r");
if (!f) { Serial.println("[HIST] Aucun fichier — démarrage à zéro"); return; }
uint16_t n = (uint16_t)(f.size() / sizeof(HistEntry));
if (n > LORES_MAX) n = LORES_MAX;
Serial.printf("[HIST] Chargement de %d pts depuis hist.bin\n", n);
HistEntry e;
for (uint16_t i = 0; i < n; i++) {
f.read((uint8_t*)&e, sizeof(e));
lrBat[lrTete] = e.bat;
lrPV[lrTete] = e.pv;
lrLoad[lrTete] = e.load;
lrSOC[lrTete] = e.soc;
lrTete = (lrTete + 1) % LORES_MAX;
if (lrN < LORES_MAX) lrN++;
}
f.close();
}
// ─── Sérialisation JSON générique ────────────────────────────────────────────
static void serJson(String &out, const char *mode, uint16_t n,
uint16_t step_s,
float *bat, float *pv, float *load, uint8_t *soc,
uint16_t maxBuf,
uint16_t (*idxFn)(uint16_t)) {
char tmp[12];
out.reserve(n * 26 + 80);
out = "{\"mode\":\""; out += mode;
out += "\",\"step\":"; out += step_s;
out += ",\"n\":"; out += n;
out += ",\"b\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.2f", bat[idxFn(i)]);
out += tmp;
}
out += "],\"p\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.2f", pv[idxFn(i)]);
out += tmp;
}
out += "],\"l\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
snprintf(tmp, sizeof(tmp), "%.1f", load[idxFn(i)]);
out += tmp;
}
out += "],\"s\":[";
for (uint16_t i = 0; i < n; i++) {
if (i) out += ',';
out += soc[idxFn(i)];
}
out += "]}";
(void)maxBuf;
}
// ─── API publique ─────────────────────────────────────────────────────────────
void initHistorique() {
hrTete = hrN = lrTete = lrN = 0;
accBat = accPV = accLoad = 0;
accSOC = accN = lrDepuisHr = 0;
premierPointAjoute = false;
unsigned long t = millis();
tDernMin = tDern5Min = tDernHr = t;
chargerFichier();
debugLogf("[HIST] Init — hires=%u lores=%u", hrN, lrN);
}
void gererHistorique() {
if (!state.rs485_ok) return;
unsigned long maintenant = millis();
if (!premierPointAjoute) {
premierPointAjoute = true;
tDernMin = maintenant;
pushHires();
} else if (maintenant - tDernMin >= 60000UL) {
tDernMin = maintenant;
pushHires();
}
if (maintenant - tDern5Min >= 300000UL) {
tDern5Min = maintenant;
pushLores();
}
if (maintenant - tDernHr >= 3600000UL) {
tDernHr = maintenant;
sauvegarderHeure();
}
}
// Lores : jusqu'à 30h, résolution 5 min
void getHistoriqueJson(String &out) {
serJson(out, "lores", lrN, 300,
lrBat, lrPV, lrLoad, lrSOC, LORES_MAX, lrIdx);
}
// Hires : jusqu'à 4h, résolution 1 min
void getHistoriqueHiresJson(String &out) {
serJson(out, "hires", hrN, 60,
hrBat, hrPV, hrLoad, hrSOC, HIRES_MAX, hrIdx);
}
// Export CSV lores (30h, pas 5 min) — temps relatif en minutes depuis maintenant
void getHistoriqueCsv(String &out) {
out.reserve(lrN * 32 + 64);
out = "temps_min,batterie_V,pv_V,charge_W,soc_pct\r\n";
char tmp[48];
for (uint16_t i = 0; i < lrN; i++) {
uint16_t idx = lrIdx(i);
int32_t min = -((int32_t)(lrN - 1 - i) * 5);
snprintf(tmp, sizeof(tmp), "%ld,%.2f,%.2f,%.1f,%d\r\n",
(long)min, lrBat[idx], lrPV[idx], lrLoad[idx], lrSOC[idx]);
out += tmp;
}
}
void getHistoriqueStatusJson(String &out) {
out = "{";
out += "\"hires_n\":"; out += hrN;
out += ",\"hires_max\":"; out += HIRES_MAX;
out += ",\"lores_n\":"; out += lrN;
out += ",\"lores_max\":"; out += LORES_MAX;
out += ",\"acc_n\":"; out += accN;
out += ",\"rs485_ok\":"; out += state.rs485_ok ? "true" : "false";
out += ",\"last_update\":"; out += state.last_update;
out += ",\"millis\":"; out += millis();
out += ",\"next_hires_ms\":";
unsigned long now = millis();
out += (now - tDernMin >= 60000UL) ? 0 : (60000UL - (now - tDernMin));
out += ",\"next_lores_ms\":";
out += (now - tDern5Min >= 300000UL) ? 0 : (300000UL - (now - tDern5Min));
out += "}";
}
+59
View File
@@ -0,0 +1,59 @@
#include <Arduino.h>
#include "config.h"
#include "state.h"
#include "wifi_ap.h"
#include "webserver.h"
#include "ota.h"
#include "buttons.h"
#include "modbus_epever.h"
#include "rules.h"
#include "sleep.h"
#include "historique.h"
#include "debug_log.h"
#include "epever_config.h"
#include "wireguard_vpn.h"
// Instance globale partagée entre tous les modules
SystemState state;
void setup() {
Serial.begin(115200);
Serial.println("\n==============================");
Serial.println(" KC868-A2 Contrôleur solaire");
Serial.printf (" Reset reason : %d\n", (int)esp_reset_reason());
Serial.println("==============================");
debugLogf("Boot KC868-A2 — reset reason %d", (int)esp_reset_reason());
// Réveil timer : vérification rapide — peut retourner en deep sleep ici
verifierEtDormirSiNuit();
// Init GPIO relais
pinMode(PIN_RELAY1, OUTPUT);
pinMode(PIN_RELAY2, OUTPUT);
restaurerRelaisNVS(); // restaure depuis NVS (survit au power-off)
demarrerWifi();
demarrerWebserveur(); // monte LittleFS
demarrerOTA();
initBoutons();
initModbus();
chargerConfigSleep(); // après montage LittleFS
restaurerRelais(); // restaure l'état relais si réveil depuis deep sleep
initRegles();
initHistorique();
initConfigEpever();
initWireGuard();
debugLogf("Système prêt.");
}
void loop() {
gererWifi();
gererWireGuard();
gererOTA();
gererBoutons();
gererModbus();
gererRegles();
gererHistorique();
gererSleep();
}
+757
View File
@@ -0,0 +1,757 @@
#include <ModbusRTU.h>
#include <Arduino.h>
#include <Preferences.h>
#include <time.h>
#include <sys/time.h>
#include "config.h"
#include "state.h"
#include "debug_log.h"
static ModbusRTU mb;
static uint32_t intervalleJour = INTERVALLE_MODBUS; // ms — mode soleil
static uint32_t intervalleNuit = 30000UL; // ms — mode veille
static uint32_t intervalCourant() {
if (state.last_update == 0) return intervalleJour; // première lecture toujours rapide
return state.sun ? intervalleJour : intervalleNuit;
}
void setIntervallesModbus(uint32_t jour_ms, uint32_t nuit_ms) {
intervalleJour = jour_ms;
intervalleNuit = nuit_ms;
Preferences p; p.begin("modbus", false);
p.putUInt("jour", jour_ms);
p.putUInt("nuit", nuit_ms);
p.end();
Serial.printf("[Modbus] Intervalles — jour:%ums nuit:%ums\n", jour_ms, nuit_ms);
}
void getIntervallesModbus(uint32_t &jour_ms, uint32_t &nuit_ms) {
jour_ms = intervalleJour;
nuit_ms = intervalleNuit;
}
// Buffers de réception — un par groupe de registres
static uint16_t bufPV[8]; // 0x3100..0x3107 : PV, batterie, courant/puissance charge
static uint16_t bufLoad[5]; // 0x310C..0x3110 : load + température batterie
static uint16_t bufSOC[1]; // 0x311A : SOC %
static uint16_t bufStatus[2]; // 0x3200 : Battery status | 0x3201 : Charging status
static uint16_t bufEnergie[16]; // 0x3304..0x3313 : kWh consommés/générés
static bool bufJourNuit[1]; // 0x200C : jour/nuit (FC02 discrete input)
static unsigned long tDerniereLecture = 0;
static unsigned long tDebutRequete = 0;
static bool lectureEnCours = false;
static uint32_t nbLecturesOK = 0;
static uint32_t nbErreurs = 0;
static uint8_t derniereErreur = 0;
static const char *derniereEtape = "aucune";
static unsigned long tDerniereSyncRtc = 0;
static bool rtcSyncedOnce = false;
static const unsigned long INTERVALLE_SYNC_RTC = 21600000UL; // 6h
static bool dernierSun = false;
static bool dernierSunValide = false;
static void finaliserLecture();
static uint16_t crc16Modbus(const uint8_t *buf, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= buf[i];
for (uint8_t bit = 0; bit < 8; bit++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
}
return crc;
}
static void dumpHex(const char *prefix, const uint8_t *buf, size_t len) {
String ligne;
ligne.reserve(40 + len * 3);
ligne += prefix;
ligne += " (";
ligne += (unsigned)len;
ligne += " octets):";
for (size_t i = 0; i < len; i++) {
char hex[4];
snprintf(hex, sizeof(hex), " %02X", buf[i]);
ligne += hex;
}
Serial.println(ligne);
debugLogLine(ligne.c_str());
}
static void viderRx(const char *raison) {
uint8_t buf[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
while (Serial2.available() && n < sizeof(buf)) {
buf[n++] = (uint8_t)Serial2.read();
}
while (Serial2.available()) Serial2.read();
if (n > 0) {
debugLogf("[Modbus][debug] RX vidé avant %s", raison);
dumpHex("[Modbus][debug] octets parasites", buf, n);
}
}
static bool probeRegistreBatterie(uint32_t baudrate) {
#if MODBUS_DEBUG_BOOT
debugLogf("[Modbus][probe] Test direct 0x3104 à %u bauds, esclave %d",
baudrate, MODBUS_ADRESSE);
Serial2.end();
delay(20);
Serial2.begin(baudrate, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
delay(50);
viderRx("probe");
uint8_t req[8] = {
MODBUS_ADRESSE,
0x04,
0x31, 0x04,
0x00, 0x01,
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
dumpHex("[Modbus][probe] TX", req, sizeof(req));
Serial2.write(req, sizeof(req));
Serial2.flush();
uint8_t resp[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
unsigned long t0 = millis();
unsigned long dernierOctet = t0;
while ((millis() - t0) < 700 && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) {
resp[n++] = (uint8_t)Serial2.read();
dernierOctet = millis();
}
if (n >= 7 && (millis() - dernierOctet) > 20) break;
delay(1);
}
if (n == 0) {
debugLogf("[Modbus][probe] Aucun octet reçu à %u bauds", baudrate);
debugLogf("[Modbus][probe] Causes probables: A/B inversés, GND absent, mauvais baudrate, mauvais ID, Epever non alimenté.");
return false;
}
dumpHex("[Modbus][probe] RX", resp, n);
if (n < 5) {
debugLogf("[Modbus][probe] Réponse trop courte: bruit RS485 ou baudrate incorrect probable.");
return false;
}
if (resp[0] != MODBUS_ADRESSE) {
debugLogf("[Modbus][probe] Adresse inattendue: reçu %u, attendu %u",
resp[0], MODBUS_ADRESSE);
return false;
}
if (resp[1] & 0x80) {
debugLogf("[Modbus][probe] Exception Modbus fonction 0x%02X code 0x%02X",
resp[1], n > 2 ? resp[2] : 0);
return false;
}
if (resp[1] != 0x04 || resp[2] != 0x02 || n < 7) {
debugLogf("[Modbus][probe] Format inattendu pour lecture input register 0x3104.");
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 5);
uint16_t crcRx = (uint16_t)resp[5] | ((uint16_t)resp[6] << 8);
if (crcCalc != crcRx) {
debugLogf("[Modbus][probe] CRC invalide: calcul 0x%04X, reçu 0x%04X", crcCalc, crcRx);
return false;
}
uint16_t brut = ((uint16_t)resp[3] << 8) | resp[4];
debugLogf("[Modbus][probe] OK à %u bauds: batterie %.2f V (brut 0x%04X)",
baudrate, brut * 0.01f, brut);
return true;
#else
(void)baudrate;
return false;
#endif
}
static bool lireRegistresBrutsFc(uint8_t fonction, uint16_t registre, uint16_t quantite,
uint16_t *dest, uint16_t timeoutMs, bool logSucces = false) {
if (quantite == 0 || quantite > 24) return false;
viderRx("lecture brute");
uint8_t req[8] = {
MODBUS_ADRESSE,
fonction,
(uint8_t)(registre >> 8), (uint8_t)(registre & 0xFF),
(uint8_t)(quantite >> 8), (uint8_t)(quantite & 0xFF),
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
Serial2.write(req, sizeof(req));
Serial2.flush();
const size_t attendu = 5 + (size_t)quantite * 2;
uint8_t resp[MODBUS_DEBUG_RX_MAX];
size_t n = 0;
unsigned long t0 = millis();
unsigned long dernierOctet = t0;
while ((millis() - t0) < timeoutMs && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) {
resp[n++] = (uint8_t)Serial2.read();
dernierOctet = millis();
}
if (n >= attendu && (millis() - dernierOctet) > 5) break;
delay(1);
}
if (n == 0) {
nbErreurs++;
derniereErreur = 0xE0;
derniereEtape = "Brut";
debugLogf("[Modbus][brut] Timeout FC%02u registre 0x%04X, aucun octet reçu", fonction, registre);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
return false;
}
if (n < attendu) {
nbErreurs++;
derniereErreur = 0xE2;
derniereEtape = "Brut court";
debugLogf("[Modbus][brut] Réponse courte FC%02u registre 0x%04X: reçu %u, attendu %u",
fonction, registre, (unsigned)n, (unsigned)attendu);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, attendu - 2);
uint16_t crcRx = (uint16_t)resp[attendu - 2] | ((uint16_t)resp[attendu - 1] << 8);
if (crcCalc != crcRx) {
nbErreurs++;
derniereErreur = 0xE1;
derniereEtape = "Brut CRC";
debugLogf("[Modbus][brut] CRC invalide FC%02u registre 0x%04X: calcul 0x%04X, reçu 0x%04X",
fonction, registre, crcCalc, crcRx);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
if (resp[0] != MODBUS_ADRESSE || resp[1] != fonction || resp[2] != quantite * 2) {
nbErreurs++;
derniereErreur = resp[1];
derniereEtape = "Brut format";
debugLogf("[Modbus][brut] Format inattendu FC%02u registre 0x%04X", fonction, registre);
dumpHex("[Modbus][brut] TX", req, sizeof(req));
dumpHex("[Modbus][brut] RX", resp, n);
return false;
}
for (uint16_t i = 0; i < quantite; i++) {
dest[i] = ((uint16_t)resp[3 + i * 2] << 8) | resp[4 + i * 2];
}
if (logSucces) {
debugLogf("[Modbus][brut] OK FC%02u registre 0x%04X x%u", fonction, registre, quantite);
}
return true;
}
static bool lireRegistresBruts(uint16_t registre, uint16_t quantite, uint16_t *dest,
uint16_t timeoutMs, bool logSucces = false) {
return lireRegistresBrutsFc(0x04, registre, quantite, dest, timeoutMs, logSucces);
}
static bool lireHoldingBruts(uint16_t registre, uint16_t quantite, uint16_t *dest,
uint16_t timeoutMs, bool logSucces = false) {
return lireRegistresBrutsFc(0x03, registre, quantite, dest, timeoutMs, logSucces);
}
static bool ecrireHoldingMultiplesBrut(uint16_t registre, uint16_t quantite,
const uint16_t *valeurs, uint16_t timeoutMs) {
if (quantite == 0 || quantite > 12) return false;
viderRx("écriture holding brute");
uint8_t req[MODBUS_DEBUG_RX_MAX];
size_t len = 7 + (size_t)quantite * 2 + 2;
if (len > sizeof(req)) return false;
req[0] = MODBUS_ADRESSE;
req[1] = 0x10;
req[2] = registre >> 8;
req[3] = registre & 0xFF;
req[4] = quantite >> 8;
req[5] = quantite & 0xFF;
req[6] = quantite * 2;
for (uint16_t i = 0; i < quantite; i++) {
req[7 + i * 2] = valeurs[i] >> 8;
req[8 + i * 2] = valeurs[i] & 0xFF;
}
uint16_t crc = crc16Modbus(req, len - 2);
req[len - 2] = crc & 0xFF;
req[len - 1] = crc >> 8;
dumpHex("[Modbus][write] TX", req, len);
Serial2.write(req, len);
Serial2.flush();
uint8_t resp[8];
size_t n = 0;
unsigned long t0 = millis();
while ((millis() - t0) < timeoutMs && n < sizeof(resp)) {
while (Serial2.available() && n < sizeof(resp)) resp[n++] = (uint8_t)Serial2.read();
if (n >= 8) break;
delay(1);
}
if (n < 8) {
debugLogf("[Modbus][write] Réponse courte FC16 registre 0x%04X: %u octets", registre, (unsigned)n);
if (n) dumpHex("[Modbus][write] RX", resp, n);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 6);
uint16_t crcRx = (uint16_t)resp[6] | ((uint16_t)resp[7] << 8);
bool ok = resp[0] == MODBUS_ADRESSE && resp[1] == 0x10 &&
resp[2] == (registre >> 8) && resp[3] == (registre & 0xFF) &&
resp[4] == (quantite >> 8) && resp[5] == (quantite & 0xFF) &&
crcCalc == crcRx;
dumpHex("[Modbus][write] RX", resp, n);
debugLogf("[Modbus][write] FC16 0x%04X x%u -> %s", registre, quantite, ok ? "OK" : "ERREUR");
return ok;
}
static bool lireEntreesDiscretesBrut(uint16_t adresse, bool *dest, uint16_t timeoutMs) {
viderRx("lecture discrete brute");
uint8_t req[8] = {
MODBUS_ADRESSE,
0x02,
(uint8_t)(adresse >> 8), (uint8_t)(adresse & 0xFF),
0x00, 0x01,
0x00, 0x00
};
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
Serial2.write(req, sizeof(req));
Serial2.flush();
uint8_t resp[8];
size_t n = 0;
unsigned long t0 = millis();
while ((millis() - t0) < timeoutMs && n < 6) {
while (Serial2.available() && n < sizeof(resp)) resp[n++] = (uint8_t)Serial2.read();
if (n >= 6) break;
delay(1);
}
if (n < 6) {
debugLogf("[Modbus][brut] Jour/nuit 0x%04X ignoré: pas de réponse FC02", adresse);
return false;
}
uint16_t crcCalc = crc16Modbus(resp, 4);
uint16_t crcRx = (uint16_t)resp[4] | ((uint16_t)resp[5] << 8);
if (resp[0] != MODBUS_ADRESSE || resp[1] != 0x02 || resp[2] != 1 || crcCalc != crcRx) {
debugLogf("[Modbus][brut] Jour/nuit 0x%04X ignoré: format/CRC invalide", adresse);
dumpHex("[Modbus][brut] RX FC02", resp, n);
return false;
}
*dest = (resp[3] & 0x01) != 0;
debugLogf("[Modbus][brut] FC02 0x%04X = %u", adresse, *dest ? 1 : 0);
return true;
}
static float u32x100(const uint16_t *reg, uint8_t indexL) {
// Le PDF EPEVER indique les 32 bits en deux registres: L puis H.
return (((uint32_t)reg[indexL + 1] << 16) | reg[indexL]) * 0.01f;
}
static void calerHorlogeEspDepuisEpever() {
struct tm tmRtc = {};
tmRtc.tm_year = state.epeverYear - 1900;
tmRtc.tm_mon = state.epeverMonth - 1;
tmRtc.tm_mday = state.epeverDay;
tmRtc.tm_hour = state.epeverHour;
tmRtc.tm_min = state.epeverMinute;
tmRtc.tm_sec = state.epeverSecond;
time_t epoch = mktime(&tmRtc);
if (epoch <= 0) {
state.espClockOk = false;
debugLogf("[RTC] Impossible de convertir l'heure Epever en epoch");
return;
}
timeval tv = { epoch, 0 };
settimeofday(&tv, nullptr);
state.espClockOk = true;
debugLogf("[RTC] Horloge ESP32 calée depuis Epever");
}
static void lireHorlogeEpever(bool force) {
unsigned long maintenant = millis();
if (!force && rtcSyncedOnce && (maintenant - tDerniereSyncRtc) < INTERVALLE_SYNC_RTC) return;
uint16_t rtc[3];
if (!lireHoldingBruts(0x9013, 3, rtc, 700, true)) {
state.epeverClockOk = false;
debugLogf("[Modbus][rtc] Horloge Epever indisponible");
return;
}
state.epeverSecond = rtc[0] & 0xFF;
state.epeverMinute = (rtc[0] >> 8) & 0xFF;
state.epeverHour = rtc[1] & 0xFF;
state.epeverDay = (rtc[1] >> 8) & 0xFF;
state.epeverMonth = rtc[2] & 0xFF;
state.epeverYear = 2000 + ((rtc[2] >> 8) & 0xFF);
bool valide = state.epeverSecond < 60 && state.epeverMinute < 60 &&
state.epeverHour < 24 && state.epeverDay >= 1 &&
state.epeverDay <= 31 && state.epeverMonth >= 1 &&
state.epeverMonth <= 12;
state.epeverClockOk = valide;
debugLogf("[Modbus][rtc] %04u-%02u-%02u %02u:%02u:%02u (%s)",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond,
valide ? "OK" : "invalide");
if (valide) {
tDerniereSyncRtc = maintenant;
rtcSyncedOnce = true;
calerHorlogeEspDepuisEpever();
}
}
static void enregistrerChangementSoleil(bool nouveauSun) {
if (!dernierSunValide) {
dernierSun = nouveauSun;
dernierSunValide = true;
return;
}
if (nouveauSun == dernierSun) return;
dernierSun = nouveauSun;
uint8_t idx = state.sunHistoryHead;
state.sunHistoryState[idx] = nouveauSun;
if (state.espClockOk) {
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
} else if (state.epeverClockOk) {
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"%04u-%02u-%02u %02u:%02u:%02u",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond);
} else {
snprintf(state.sunHistoryTime[idx], sizeof(state.sunHistoryTime[idx]),
"uptime %lus", millis() / 1000);
}
state.sunHistoryHead = (state.sunHistoryHead + 1) % 5;
if (state.sunHistoryCount < 5) state.sunHistoryCount++;
state.sunHistoryValid = true;
debugLogf("[SUN] Changement état -> %s à %s",
nouveauSun ? "JOUR" : "NUIT", state.sunHistoryTime[idx]);
}
static bool effectuerLectureBruteEpever() {
uint16_t pv[8];
uint16_t load[5];
uint16_t soc[1];
uint16_t status[2];
uint16_t energie[18];
bool nuit = false;
debugLogf("[Modbus][brut] Début cycle lecture Epever");
if (!lireRegistresBruts(0x3100, 8, pv, 700, true)) return false;
if (!lireRegistresBruts(0x310C, 5, load, 700, true)) return false;
if (!lireRegistresBruts(0x311A, 1, soc, 700, true)) return false;
if (!lireRegistresBruts(0x3200, 2, status, 700, true)) return false;
lireHorlogeEpever(false);
if (lireRegistresBruts(0x3302, 18, energie, 900, false)) {
// Base 0x3302 selon MODBUS-Protocol-v25.pdf:
// 0x3304/05 conso jour, 0x330A/0B conso totale,
// 0x330C/0D production jour, 0x3312/13 production totale.
state.energieConJour = u32x100(energie, 2);
state.energieConTotal = u32x100(energie, 8);
state.energieGenJour = u32x100(energie, 10);
state.energieGenTotal = u32x100(energie, 16);
debugLogf("[Modbus][brut] Energie: genJ=%.2fkWh consoJ=%.2fkWh genTot=%.2fkWh consoTot=%.2fkWh",
state.energieGenJour, state.energieConJour,
state.energieGenTotal, state.energieConTotal);
} else {
debugLogf("[Modbus][brut] Energie ignorée, les valeurs précédentes sont conservées");
}
state.pv = pv[0] * 0.01f;
state.pvCurrent = pv[1] * 0.01f;
state.battery = pv[4] * 0.01f;
state.loadVoltage = load[0] * 0.01f;
state.loadCurrent = load[1] * 0.01f;
state.loadPower = u32x100(load, 2); // 0x310E/0x310F L/H
state.batTemperature = (int16_t)load[4] * 0.01f;
state.batSOC = (uint8_t)constrain((int)soc[0], 0, 100);
uint8_t batVoltStatus = status[0] & 0x0F;
state.batSousVoltage = (batVoltStatus == 2);
state.batSurVoltage = (batVoltStatus == 1);
state.batStatut = (status[1] >> 2) & 0x03;
if (lireEntreesDiscretesBrut(0x200C, &nuit, 500)) {
// Le registre officiel dit 1=Nuit, 0=Jour. Si le PV est clairement
// présent, on force jour pour éviter un état incohérent côté UI.
state.sun = !nuit || state.pv > 2.0f;
} else {
state.sun = state.pv > 2.0f;
}
enregistrerChangementSoleil(state.sun);
debugLogf("[Modbus][brut] Bruts: 3110(temp)=0x%04X 311A(SOC)=%u 3200=0x%04X 3201=0x%04X sun=%u",
load[4], soc[0], status[0], status[1], state.sun ? 1 : 0);
finaliserLecture();
return true;
}
bool reglerHorlogeEpever(uint16_t annee, uint8_t mois, uint8_t jour,
uint8_t heure, uint8_t minute, uint8_t seconde) {
if (lectureEnCours) {
debugLogf("[Modbus][rtc] Réglage refusé: cycle lecture en cours");
return false;
}
if (annee < 2000 || annee > 2099 || mois < 1 || mois > 12 || jour < 1 || jour > 31 ||
heure > 23 || minute > 59 || seconde > 59) {
debugLogf("[Modbus][rtc] Réglage refusé: date/heure invalide");
return false;
}
uint16_t regs[3];
regs[0] = ((uint16_t)minute << 8) | seconde; // 0x9013
regs[1] = ((uint16_t)jour << 8) | heure; // 0x9014
regs[2] = ((uint16_t)(annee - 2000) << 8) | mois; // 0x9015
lectureEnCours = true;
bool ok = ecrireHoldingMultiplesBrut(0x9013, 3, regs, 1000);
lectureEnCours = false;
if (ok) {
rtcSyncedOnce = false;
lireHorlogeEpever(true);
}
return ok;
}
static void debugBootModbus() {
#if MODBUS_DEBUG_BOOT
debugLogf("--- Diagnostic RS485 boot ---");
debugLogf(" UART ESP32 : Serial2");
debugLogf(" RX GPIO : %d", PIN_RS485_RX);
debugLogf(" TX GPIO : %d", PIN_RS485_TX);
debugLogf(" Adresse Epever : %d", MODBUS_ADRESSE);
debugLogf(" Baud principal : %u", (uint32_t)MODBUS_BAUDRATE);
debugLogf(" Trame test : FC04 registre 0x3104 tension batterie");
debugLogf(" Rappel câblage : Epever A/D+ vers KC868 A, Epever B/D- vers KC868 B, GND recommandé");
bool okPrincipal = probeRegistreBatterie(MODBUS_BAUDRATE);
if (!okPrincipal && MODBUS_BAUDRATE != 9600) probeRegistreBatterie(9600);
if (!okPrincipal && MODBUS_BAUDRATE != 115200) probeRegistreBatterie(115200);
debugLogf("--- Fin diagnostic RS485 boot ---");
#endif
}
// Fin de chaîne — appelé après la dernière lecture
static void finaliserLecture() {
state.rs485_ok = true;
state.last_update = millis();
lectureEnCours = false;
nbLecturesOK++;
debugLogf("Modbus OK #%u — Bat:%.2fV %d%% PV:%.2fV %.2fA Load:%.1fW %s",
nbLecturesOK,
state.battery, state.batSOC, state.pv, state.pvCurrent,
state.loadPower, state.sun ? "JOUR" : "NUIT");
}
static const char* codeModbus(uint8_t code) {
switch (code) {
case 0x01: return "Fonction non supportée";
case 0x02: return "Adresse registre invalide";
case 0x03: return "Valeur invalide";
case 0x04: return "Erreur matérielle esclave";
case 0xE0: return "Timeout (pas de réponse)";
case 0xE1: return "CRC invalide";
case 0xE2: return "Exception générale";
default: return "Inconnu";
}
}
static void erreurLecture(const char *etape, uint8_t code) {
state.rs485_ok = false;
lectureEnCours = false;
nbErreurs++;
derniereErreur = code;
derniereEtape = etape;
debugLogf("Modbus ERREUR #%u [%s] code=0x%02X (%s), ok=%u, uptime=%lums",
nbErreurs, etape, code, codeModbus(code), nbLecturesOK, millis());
debugLogf("[Modbus][aide] Si timeout: vérifier A/B, GND, baudrate, ID=%d, RJ45 Epever non branché sur Ethernet.",
MODBUS_ADRESSE);
}
// Chaîne de lectures : PV → Load → SOC → Status → Energie → JourNuit → fin
static bool cbJourNuit(Modbus::ResultCode ev, uint16_t, void*) {
if (ev == Modbus::EX_SUCCESS) {
// 0x200C FC02 : bit D0 = 1 → Nuit, 0 → Jour
state.sun = !bufJourNuit[0];
} else {
Serial.printf("Modbus [JourNuit] : 0x%02X — ignoré\n", ev);
}
finaliserLecture(); // non-fatal : on finalise dans tous les cas
return true;
}
static bool cbEnergie(Modbus::ResultCode ev, uint16_t, void*) {
if (ev == Modbus::EX_SUCCESS) {
// Lecture depuis 0x3300, registres 32 bits little-endian (L word first)
state.energieGenJour = ((uint32_t)bufEnergie[1] << 16 | bufEnergie[0]) * 0.01f; // 0x3300-01
state.energieGenTotal = ((uint32_t)bufEnergie[7] << 16 | bufEnergie[6]) * 0.01f; // 0x3306-07
state.energieConJour = ((uint32_t)bufEnergie[9] << 16 | bufEnergie[8]) * 0.01f; // 0x3308-09
state.energieConTotal = ((uint32_t)bufEnergie[15] << 16 | bufEnergie[14]) * 0.01f; // 0x330E-0F
tDebutRequete = millis();
mb.readIsts(MODBUS_ADRESSE, 0x200C, bufJourNuit, 1, cbJourNuit); // FC02 discrete input
} else {
Serial.printf("Modbus [Energie] : 0x%02X — ignoré\n", ev);
finaliserLecture(); // non-fatal
}
return true;
}
static bool cbStatus(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("Status", ev); return true; }
// 0x3200 D3-D0 : 00=Normal, 01=Over voltage, 02=Under voltage, 03=Over discharge
uint8_t batVoltStatus = bufStatus[0] & 0x0F;
state.batSousVoltage = (batVoltStatus == 2);
state.batSurVoltage = (batVoltStatus == 1);
// 0x3201 D3-D2 : 00=No charge, 01=Float, 02=Boost, 03=Equalization
state.batStatut = (bufStatus[1] >> 2) & 0x03;
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x3300, bufEnergie, 16, cbEnergie);
return true;
}
static bool cbSOC(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("SOC", ev); return true; }
state.batSOC = (uint8_t)bufSOC[0];
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x3200, bufStatus, 2, cbStatus); // 0x3200 + 0x3201
return true;
}
static bool cbLoad(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("Load", ev); return true; }
state.loadVoltage = bufLoad[0] * 0.01f; // 0x310C
state.loadCurrent = bufLoad[1] * 0.01f; // 0x310D
state.loadPower = bufLoad[2] * 0.01f; // 0x310E
// bufLoad[3] = 0x310F réservé
state.batTemperature = (int16_t)bufLoad[4] * 0.01f; // 0x3110 signé
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x311A, bufSOC, 1, cbSOC);
return true;
}
static bool cbPV(Modbus::ResultCode ev, uint16_t, void*) {
if (ev != Modbus::EX_SUCCESS) { erreurLecture("PV", ev); return true; }
state.pv = bufPV[0] * 0.01f; // 0x3100 Tension PV
state.pvCurrent = bufPV[1] * 0.01f; // 0x3101 Courant PV
state.battery = bufPV[4] * 0.01f; // 0x3104 Tension batterie
tDebutRequete = millis();
mb.readIreg(MODBUS_ADRESSE, 0x310C, bufLoad, 5, cbLoad);
return true;
}
void initModbus() {
Preferences p; p.begin("modbus", true);
intervalleJour = p.getUInt("jour", INTERVALLE_MODBUS);
intervalleNuit = p.getUInt("nuit", 30000UL);
p.end();
debugLogf("--- Modbus init ---");
debugLogf(" Adresse esclave : %d", MODBUS_ADRESSE);
debugLogf(" Baud rate : %u", (uint32_t)MODBUS_BAUDRATE);
debugLogf(" TX GPIO : %d", PIN_RS485_TX);
debugLogf(" RX GPIO : %d", PIN_RS485_RX);
debugLogf(" Timeout requête : %d ms", TIMEOUT_MODBUS);
debugLogf(" Intervalle jour : %u ms", intervalleJour);
debugLogf(" Intervalle nuit : %u ms", intervalleNuit);
debugBootModbus();
Serial2.end();
delay(20);
Serial2.begin(MODBUS_BAUDRATE, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
viderRx("démarrage ModbusRTU");
mb.begin(&Serial2);
mb.master();
debugLogf(" → Serial2 + Modbus master démarrés");
debugLogf("-------------------");
}
// ---------------------------------------------------------------------------
// API partagée pour epever_config.cpp
// ---------------------------------------------------------------------------
bool isModbusBusy() { return lectureEnCours; }
bool modbusAcquire() { if (lectureEnCours) return false; lectureEnCours = true; return true; }
void modbusRelease() { lectureEnCours = false; }
bool modbusLireHolding(uint16_t reg, uint16_t qty, uint16_t *dest, uint16_t timeoutMs) {
return lireHoldingBruts(reg, qty, dest, timeoutMs);
}
bool modbusEcrireHolding(uint16_t reg, uint16_t qty, const uint16_t *vals, uint16_t timeoutMs) {
return ecrireHoldingMultiplesBrut(reg, qty, vals, timeoutMs);
}
void gererModbus() {
unsigned long maintenant = millis();
if (!lectureEnCours && (maintenant - tDerniereLecture) >= intervalCourant()) {
tDerniereLecture = maintenant;
tDebutRequete = maintenant;
lectureEnCours = true;
debugLogf("[Modbus] Début lecture brute — uptime=%lus, baud=%u, ID=%d, erreurs=%u, dernière=%s/0x%02X",
maintenant / 1000, (uint32_t)MODBUS_BAUDRATE, MODBUS_ADRESSE,
nbErreurs, derniereEtape, derniereErreur);
if (!effectuerLectureBruteEpever()) {
state.rs485_ok = false;
lectureEnCours = false;
debugLogf("[Modbus][brut] Cycle échoué — dernière=%s/0x%02X, erreurs=%u",
derniereEtape, derniereErreur, nbErreurs);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
#include <ElegantOTA.h>
#include "config.h"
#include "webserver.h"
void demarrerOTA() {
ElegantOTA.begin(&server);
Serial.println("OTA disponible sur http://192.168.4.1/update (sans authentification)");
}
// Doit être appelé dans loop() pour que l'OTA async fonctionne
void gererOTA() {
ElegantOTA.loop();
}
+220
View File
@@ -0,0 +1,220 @@
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "config.h"
#include "state.h"
#include "rules.h"
#define MAX_REGLES 20
#define FICHIER_REGLES "/rules.json"
struct Regle {
int id;
bool enabled;
// Déclencheurs
int8_t sun; // -1=ignoré, 0=nuit requis, 1=jour requis
int8_t di1; // -1=ignoré, 0=ouvert requis, 1=fermé requis
int8_t di2; // -1=ignoré, 0=ouvert requis, 1=fermé requis
// Conditions
float batteryMin; // seuil min batterie en V (0 = ignoré)
float batteryMax; // seuil max batterie en V (0 = ignoré)
float pvMin; // seuil min PV en V (0 = ignoré)
float pvMax; // seuil max PV en V (0 = ignoré)
// Action
uint8_t relay; // 1 ou 2
bool etat; // true=ON, false=OFF
uint32_t delai; // délai avant action (secondes)
float hysteresis; // bande morte en V
// État runtime — non persisté
bool delaiEnCours;
unsigned long tDebutDelai;
bool estActif;
};
static Regle regles[MAX_REGLES];
static int nbRegles = 0;
static unsigned long tDerniereEval = 0;
// --- Persistance ---
static int8_t parseTri(JsonObject &obj, const char *key) {
if (!obj[key].is<bool>()) return -1;
return obj[key].as<bool>() ? 1 : 0;
}
static void jsonVersRegle(JsonObject obj, Regle &r) {
r.enabled = obj["enabled"] | true;
r.sun = parseTri(obj, "sun");
r.di1 = parseTri(obj, "di1");
r.di2 = parseTri(obj, "di2");
r.batteryMin = obj["battery_min"] | 0.0f;
r.batteryMax = obj["battery_max"] | 0.0f;
r.pvMin = obj["pv_min"] | 0.0f;
r.pvMax = obj["pv_max"] | 0.0f;
r.relay = obj["relay"] | 1;
r.etat = obj["state"] | false;
r.delai = obj["delay"] | 0u;
r.hysteresis = obj["hysteresis"] | 0.0f;
r.delaiEnCours = false;
r.tDebutDelai = 0;
r.estActif = false;
}
static void chargerRegles() {
nbRegles = 0;
if (!LittleFS.exists(FICHIER_REGLES)) {
Serial.println("rules.json absent — aucune règle chargée");
return;
}
File f = LittleFS.open(FICHIER_REGLES, "r");
if (!f) { Serial.println("Erreur ouverture rules.json"); return; }
JsonDocument doc;
if (deserializeJson(doc, f)) {
Serial.println("Erreur parsing rules.json");
f.close();
return;
}
f.close();
for (JsonObject obj : doc.as<JsonArray>()) {
if (nbRegles >= MAX_REGLES) break;
regles[nbRegles].id = obj["id"] | (nbRegles + 1);
jsonVersRegle(obj, regles[nbRegles]);
nbRegles++;
}
Serial.printf("%d règle(s) chargée(s)\n", nbRegles);
}
static bool sauvegarderRegles() {
File f = LittleFS.open(FICHIER_REGLES, "w");
if (!f) { Serial.println("Erreur écriture rules.json"); return false; }
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
reglesToJson(arr);
serializeJson(doc, f);
f.close();
return true;
}
// --- Logique d'évaluation ---
// Hystérésis : quand la règle est active, les seuils sont relâchés d'une
// bande `hysteresis` pour éviter les oscillations autour du point de consigne.
static bool conditionsSatisfaites(const Regle &r) {
// Seuils batterie avec hystérésis si la règle était déjà active
float batMinEff = (r.hysteresis > 0 && r.estActif) ? r.batteryMin - r.hysteresis : r.batteryMin;
float batMaxEff = (r.hysteresis > 0 && r.estActif) ? r.batteryMax + r.hysteresis : r.batteryMax;
float pvMinEff = (r.hysteresis > 0 && r.estActif) ? r.pvMin - r.hysteresis : r.pvMin;
float pvMaxEff = (r.hysteresis > 0 && r.estActif) ? r.pvMax + r.hysteresis : r.pvMax;
// Déclencheurs
if (r.sun >= 0 && (bool)(r.sun == 1) != state.sun) return false;
if (r.di1 >= 0 && (bool)(r.di1 == 1) != state.di1) return false;
if (r.di2 >= 0 && (bool)(r.di2 == 1) != state.di2) return false;
// Conditions
if (r.batteryMin > 0 && state.battery < batMinEff) return false;
if (r.batteryMax > 0 && state.battery > batMaxEff) return false;
if (r.pvMin > 0 && state.pv < pvMinEff) return false;
if (r.pvMax > 0 && state.pv > pvMaxEff) return false;
return true;
}
static void appliquerAction(const Regle &r) {
if (r.relay == 1) {
state.relay1 = r.etat;
digitalWrite(PIN_RELAY1, r.etat ? HIGH : LOW);
} else if (r.relay == 2) {
state.relay2 = r.etat;
digitalWrite(PIN_RELAY2, r.etat ? HIGH : LOW);
}
Serial.printf("Règle %d appliquée — relais %d : %s\n", r.id, r.relay, r.etat ? "ON" : "OFF");
}
// --- API publique ---
void initRegles() {
chargerRegles();
}
void gererRegles() {
unsigned long maintenant = millis();
if (maintenant - tDerniereEval < INTERVALLE_REGLES) return;
tDerniereEval = maintenant;
for (int i = 0; i < nbRegles; i++) {
Regle &r = regles[i];
if (!r.enabled) continue;
if (conditionsSatisfaites(r)) {
r.estActif = true;
if (r.delai == 0) {
appliquerAction(r);
} else if (!r.delaiEnCours) {
r.delaiEnCours = true;
r.tDebutDelai = maintenant;
Serial.printf("Règle %d — délai %ds démarré\n", r.id, r.delai);
} else if (maintenant - r.tDebutDelai >= (unsigned long)r.delai * 1000UL) {
appliquerAction(r);
r.delaiEnCours = false;
}
} else {
r.estActif = false;
if (r.delaiEnCours) {
r.delaiEnCours = false;
Serial.printf("Règle %d — conditions perdues, délai annulé\n", r.id);
}
}
}
}
void reglesToJson(JsonArray arr) {
for (int i = 0; i < nbRegles; i++) {
const Regle &r = regles[i];
JsonObject obj = arr.add<JsonObject>();
obj["id"] = r.id;
obj["enabled"] = r.enabled;
if (r.sun >= 0) obj["sun"] = (bool)(r.sun == 1);
if (r.di1 >= 0) obj["di1"] = (bool)(r.di1 == 1);
if (r.di2 >= 0) obj["di2"] = (bool)(r.di2 == 1);
if (r.batteryMin > 0) obj["battery_min"] = r.batteryMin;
if (r.batteryMax > 0) obj["battery_max"] = r.batteryMax;
if (r.pvMin > 0) obj["pv_min"] = r.pvMin;
if (r.pvMax > 0) obj["pv_max"] = r.pvMax;
obj["relay"] = r.relay;
obj["state"] = r.etat;
obj["delay"] = r.delai;
if (r.hysteresis > 0) obj["hysteresis"] = r.hysteresis;
}
}
bool ajouterRegle(JsonObject obj) {
if (nbRegles >= MAX_REGLES) return false;
int maxId = 0;
for (int i = 0; i < nbRegles; i++) if (regles[i].id > maxId) maxId = regles[i].id;
regles[nbRegles].id = maxId + 1;
jsonVersRegle(obj, regles[nbRegles]);
nbRegles++;
return sauvegarderRegles();
}
bool supprimerRegle(int id) {
for (int i = 0; i < nbRegles; i++) {
if (regles[i].id != id) continue;
for (int j = i; j < nbRegles - 1; j++) regles[j] = regles[j + 1];
nbRegles--;
return sauvegarderRegles();
}
return false;
}
bool toggleRegle(int id) {
for (int i = 0; i < nbRegles; i++) {
if (regles[i].id != id) continue;
regles[i].enabled = !regles[i].enabled;
return sauvegarderRegles();
}
return false;
}
+155
View File
@@ -0,0 +1,155 @@
#include <WiFi.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include <esp_sleep.h>
#include "config.h"
#include "state.h"
// Persisté en mémoire RTC — survit au deep sleep, perdu au power-off complet
RTC_DATA_ATTR static bool rtcSleepActif = false; // désactivé par défaut
RTC_DATA_ATTR static uint32_t rtcIntervalle = 600; // secondes entre réveil
RTC_DATA_ATTR static float rtcSeuilSoleil = 2.0f; // V PV minimum = jour
RTC_DATA_ATTR static bool rtcRelay1 = false; // état relais sauvegardé
RTC_DATA_ATTR static bool rtcRelay2 = false;
// Runtime
static bool enModeNuit = false;
static unsigned long tDebutNuit = 0;
#define TEMPO_CONFIRMATION_NUIT 60000UL // 60s de nuit confirmée avant de dormir
#define FICHIER_SLEEP "/sleep.json"
// --- Utilitaires ---
static uint16_t crc16Modbus(const uint8_t *buf, int len) {
uint16_t crc = 0xFFFF;
for (int i = 0; i < len; i++) {
crc ^= buf[i];
for (int b = 0; b < 8; b++)
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
return crc;
}
// Lecture synchrone de la tension PV via Modbus RTU brut
// Utilisé uniquement au réveil, avant que le serveur web soit démarré
static float lirePVSync() {
uint8_t req[8] = { MODBUS_ADRESSE, 0x04, 0x31, 0x00, 0x00, 0x01, 0x00, 0x00 };
uint16_t crc = crc16Modbus(req, 6);
req[6] = crc & 0xFF;
req[7] = crc >> 8;
while (Serial2.available()) Serial2.read(); // vider buffer résiduel
Serial2.write(req, 8);
Serial2.flush();
unsigned long t = millis();
while (Serial2.available() < 7 && millis() - t < 300);
if (Serial2.available() < 7) return -1.0f;
uint8_t resp[7];
Serial2.readBytes(resp, 7);
if (resp[0] != MODBUS_ADRESSE || resp[1] != 0x04 || resp[2] != 2) return -1.0f;
return ((resp[3] << 8) | resp[4]) * 0.01f;
}
static void entrerEnDeepSleep() {
Serial.printf("Deep sleep — réveil dans %ds\n", rtcIntervalle);
Serial.flush();
WiFi.mode(WIFI_OFF);
delay(50);
esp_sleep_enable_timer_wakeup((uint64_t)rtcIntervalle * 1000000ULL);
esp_deep_sleep_start();
// Ne revient jamais ici
}
// --- API publique ---
void verifierEtDormirSiNuit() {
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_TIMER) return;
if (!rtcSleepActif) return;
Serial.println("Réveil timer — vérification ensoleillement...");
Serial2.begin(9600, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);
delay(50);
float pv = lirePVSync();
Serial2.end();
Serial.printf("PV = %.2fV (seuil %.1fV)\n", pv, rtcSeuilSoleil);
if (pv >= 0.0f && pv < rtcSeuilSoleil) {
Serial.println("Toujours nuit → re-sleep");
entrerEnDeepSleep(); // ne revient pas
}
Serial.println("Jour détecté → démarrage complet");
}
void restaurerRelais() {
if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_TIMER) return;
state.relay1 = rtcRelay1;
state.relay2 = rtcRelay2;
digitalWrite(PIN_RELAY1, rtcRelay1 ? HIGH : LOW);
digitalWrite(PIN_RELAY2, rtcRelay2 ? HIGH : LOW);
Serial.printf("Relais restaurés — R1:%d R2:%d\n", rtcRelay1, rtcRelay2);
}
void chargerConfigSleep() {
if (!LittleFS.exists(FICHIER_SLEEP)) return;
File f = LittleFS.open(FICHIER_SLEEP, "r");
if (!f) return;
JsonDocument doc;
if (!deserializeJson(doc, f)) {
rtcSleepActif = doc["actif"] | false;
rtcIntervalle = doc["intervalle"] | 600u;
rtcSeuilSoleil = doc["seuil"] | 2.0f;
}
f.close();
Serial.printf("Sleep config — actif:%d intervalle:%ds seuil:%.1fV\n",
rtcSleepActif, rtcIntervalle, rtcSeuilSoleil);
}
void gererSleep() {
if (!rtcSleepActif) return;
if (!state.rs485_ok) return; // pas de données fiables — ne pas dormir
unsigned long maintenant = millis();
if (!state.sun) {
if (!enModeNuit) {
enModeNuit = true;
tDebutNuit = maintenant;
Serial.printf("Nuit — sleep dans %lus si confirmé\n", TEMPO_CONFIRMATION_NUIT / 1000);
return;
}
if (maintenant - tDebutNuit < TEMPO_CONFIRMATION_NUIT) return;
rtcRelay1 = state.relay1; // sauvegarder état relais en RTC
rtcRelay2 = state.relay2;
entrerEnDeepSleep(); // ne revient pas
} else {
enModeNuit = false;
}
}
void getSleepConfigJson(String &out) {
JsonDocument doc;
doc["actif"] = rtcSleepActif;
doc["intervalle"] = rtcIntervalle;
doc["seuil"] = rtcSeuilSoleil;
serializeJson(doc, out);
}
bool setSleepConfig(bool actif, uint32_t intervalle, float seuil) {
rtcSleepActif = actif;
rtcIntervalle = intervalle;
rtcSeuilSoleil = seuil;
File f = LittleFS.open(FICHIER_SLEEP, "w");
if (!f) return false;
JsonDocument doc;
doc["actif"] = actif;
doc["intervalle"] = intervalle;
doc["seuil"] = seuil;
serializeJson(doc, f);
f.close();
return true;
}
+512
View File
@@ -0,0 +1,512 @@
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <time.h>
#include "config.h"
#include "state.h"
#include "webserver.h"
#include "rules.h"
#include "sleep.h"
#include "historique.h"
#include "modbus_epever.h"
#include "epever_config.h"
#include "wifi_ap.h"
#include "wireguard_vpn.h"
#include "debug_log.h"
AsyncWebServer server(80);
// --- Persistance relais (NVS — survit au power-off) ---
static void sauvegarderRelaisNVS() {
Preferences prefs;
prefs.begin("relais", false);
prefs.putBool("r1", state.relay1);
prefs.putBool("r2", state.relay2);
prefs.end();
Serial.printf("[NVS] Relais sauvegardés — R1:%d R2:%d\n", state.relay1, state.relay2);
}
void restaurerRelaisNVS() {
Preferences prefs;
prefs.begin("relais", true);
state.relay1 = prefs.getBool("r1", false);
state.relay2 = prefs.getBool("r2", false);
prefs.end();
digitalWrite(PIN_RELAY1, state.relay1 ? HIGH : LOW);
digitalWrite(PIN_RELAY2, state.relay2 ? HIGH : LOW);
Serial.printf("[NVS] Relais restaurés — R1:%d R2:%d\n", state.relay1, state.relay2);
}
// Sérialise l'état système en JSON et répond à la requête
static void envoyerEtat(AsyncWebServerRequest *request) {
JsonDocument doc;
// PV
doc["pv"] = state.pv;
doc["pvCurrent"] = state.pvCurrent;
// Batterie
doc["battery"] = state.battery;
doc["batSOC"] = state.batSOC;
doc["batTemperature"] = state.batTemperature;
doc["batStatut"] = state.batStatut;
doc["batSousVoltage"] = state.batSousVoltage;
doc["batSurVoltage"] = state.batSurVoltage;
// Load
doc["loadVoltage"] = state.loadVoltage;
doc["loadCurrent"] = state.loadCurrent;
doc["loadPower"] = state.loadPower;
// Énergie kWh
doc["energieGenJour"] = state.energieGenJour;
doc["energieGenTotal"] = state.energieGenTotal;
doc["energieConJour"] = state.energieConJour;
doc["energieConTotal"] = state.energieConTotal;
// Général
doc["sun"] = state.sun;
doc["espClockOk"] = state.espClockOk;
if (state.espClockOk) {
time_t now = time(nullptr);
struct tm tmNow;
localtime_r(&now, &tmNow);
char espTime[20];
snprintf(espTime, sizeof(espTime), "%04d-%02d-%02d %02d:%02d:%02d",
tmNow.tm_year + 1900, tmNow.tm_mon + 1, tmNow.tm_mday,
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec);
doc["espTime"] = espTime;
} else {
doc["espTime"] = "--";
}
doc["epeverClockOk"] = state.epeverClockOk;
if (state.epeverClockOk) {
char rtc[20];
snprintf(rtc, sizeof(rtc), "%04u-%02u-%02u %02u:%02u:%02u",
state.epeverYear, state.epeverMonth, state.epeverDay,
state.epeverHour, state.epeverMinute, state.epeverSecond);
doc["epeverTime"] = rtc;
} else {
doc["epeverTime"] = "--";
}
doc["relay1"] = state.relay1;
doc["relay2"] = state.relay2;
doc["di1"] = state.di1;
doc["di2"] = state.di2;
doc["autoMode"] = state.autoMode;
doc["rs485_ok"] = state.rs485_ok;
doc["last_update"] = state.last_update;
String json;
serializeJson(doc, json);
request->send(200, "application/json", json);
}
// Commande un relais et met à jour l'état global
static void commanderRelais(AsyncWebServerRequest *request, int relais, bool etat) {
if (relais == 1) {
state.relay1 = etat;
digitalWrite(PIN_RELAY1, etat ? HIGH : LOW);
} else if (relais == 2) {
state.relay2 = etat;
digitalWrite(PIN_RELAY2, etat ? HIGH : LOW);
}
Serial.printf("[WEB] Relais %d → %s (client %s)\n",
relais, etat ? "ON" : "OFF",
request->client()->remoteIP().toString().c_str());
request->send(200, "application/json", "{\"ok\":true}");
}
void demarrerWebserveur() {
if (!LittleFS.begin(true)) {
Serial.println("Erreur : impossible de monter LittleFS");
return;
}
Serial.println("LittleFS monté");
// --- API REST (définie avant le handler statique) ---
server.on("/api/state", HTTP_GET, envoyerEtat);
server.on("/api/debug/logs", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getDebugLogJson(json);
r->send(200, "application/json", json);
});
server.on("/api/debug/clear", HTTP_POST, [](AsyncWebServerRequest *r) {
clearDebugLog();
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/sun/history", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
JsonArray arr = doc["changes"].to<JsonArray>();
for (uint8_t i = 0; i < state.sunHistoryCount; i++) {
uint8_t idx = (state.sunHistoryHead + 5 - state.sunHistoryCount + i) % 5;
JsonObject item = arr.add<JsonObject>();
item["sun"] = state.sunHistoryState[idx];
item["label"] = state.sunHistoryState[idx] ? "Jour" : "Nuit";
item["time"] = state.sunHistoryTime[idx];
}
String json;
serializeJson(doc, json);
r->send(200, "application/json", json);
});
server.on("/api/relay/1/on", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 1, true); });
server.on("/api/relay/1/off", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 1, false); });
server.on("/api/relay/2/on", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 2, true); });
server.on("/api/relay/2/off", HTTP_POST, [](AsyncWebServerRequest *r){ commanderRelais(r, 2, false); });
// Toggle + sauvegarde NVS (appui long dashboard)
server.on("/api/relay/1/toggle", HTTP_POST, [](AsyncWebServerRequest *r){
state.relay1 = !state.relay1;
digitalWrite(PIN_RELAY1, state.relay1 ? HIGH : LOW);
sauvegarderRelaisNVS();
Serial.printf("[WEB] Relais 1 toggle → %s (NVS sauvegardé)\n", state.relay1 ? "ON" : "OFF");
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/relay/2/toggle", HTTP_POST, [](AsyncWebServerRequest *r){
state.relay2 = !state.relay2;
digitalWrite(PIN_RELAY2, state.relay2 ? HIGH : LOW);
sauvegarderRelaisNVS();
Serial.printf("[WEB] Relais 2 toggle → %s (NVS sauvegardé)\n", state.relay2 ? "ON" : "OFF");
r->send(200, "application/json", "{\"ok\":true}");
});
server.on("/api/reboot", HTTP_POST, [](AsyncWebServerRequest *r){
Serial.println("[WEB] Reboot demandé");
r->send(200, "application/json", "{\"ok\":true}");
delay(200);
ESP.restart();
});
auto *handlerEpeverTime = new AsyncCallbackJsonWebHandler("/api/epever/time",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
uint16_t year = obj["year"] | 0;
uint8_t month = obj["month"] | 0;
uint8_t day = obj["day"] | 0;
uint8_t hour = obj["hour"] | 0;
uint8_t minute = obj["minute"] | 0;
uint8_t second = obj["second"] | 0;
bool ok = reglerHorlogeEpever(year, month, day, hour, minute, second);
r->send(ok ? 200 : 409, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerEpeverTime);
// --- API règles ---
server.on("/api/rules", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
reglesToJson(doc.to<JsonArray>());
String json;
serializeJson(doc, json);
r->send(200, "application/json", json);
});
server.on("/api/rules/toggle", HTTP_POST, [](AsyncWebServerRequest *r) {
if (!r->hasParam("id")) { r->send(400); return; }
int id = r->getParam("id")->value().toInt();
bool ok = toggleRegle(id);
Serial.printf("[WEB] Règle %d toggle → %s\n", id, ok ? "ok" : "introuvable");
r->send(ok ? 200 : 404, "application/json", "{\"ok\":true}");
});
server.on("/api/rules/delete", HTTP_POST, [](AsyncWebServerRequest *r) {
if (!r->hasParam("id")) { r->send(400); return; }
int id = r->getParam("id")->value().toInt();
bool ok = supprimerRegle(id);
Serial.printf("[WEB] Règle %d supprimée → %s\n", id, ok ? "ok" : "introuvable");
r->send(ok ? 200 : 404, "application/json", "{\"ok\":true}");
});
// Ajout de règle — corps JSON
auto *handlerRegle = new AsyncCallbackJsonWebHandler("/api/rules",
[](AsyncWebServerRequest *r, JsonVariant &json) {
bool ok = ajouterRegle(json.as<JsonObject>());
Serial.printf("[WEB] Ajout règle → %s\n", ok ? "ok" : "erreur");
r->send(ok ? 201 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerRegle);
// --- API historique ---
server.on("/api/history", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueJson(json); // lores : 30h, 5 min
r->send(200, "application/json", json);
});
server.on("/api/history/hires", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueHiresJson(json); // hires : 4h, 1 min
r->send(200, "application/json", json);
});
server.on("/api/history/status", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getHistoriqueStatusJson(json);
r->send(200, "application/json", json);
});
server.on("/api/history/csv", HTTP_GET, [](AsyncWebServerRequest *r) {
String csv;
getHistoriqueCsv(csv);
AsyncWebServerResponse *resp = r->beginResponse(200, "text/csv", csv);
resp->addHeader("Content-Disposition", "attachment; filename=\"historique.csv\"");
r->send(resp);
});
// --- API noms (relais / entrées) ---
server.on("/api/names", HTTP_GET, [](AsyncWebServerRequest *r) {
Preferences p; p.begin("noms", true);
JsonDocument doc;
doc["relay1"] = p.getString("r1", "Relais 1");
doc["relay2"] = p.getString("r2", "Relais 2");
doc["di1"] = p.getString("d1", "Entrée 1");
doc["di2"] = p.getString("d2", "Entrée 2");
p.end();
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerNoms = new AsyncCallbackJsonWebHandler("/api/names",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
Preferences p; p.begin("noms", false);
if (obj["relay1"].is<const char*>()) p.putString("r1", obj["relay1"].as<const char*>());
if (obj["relay2"].is<const char*>()) p.putString("r2", obj["relay2"].as<const char*>());
if (obj["di1"].is<const char*>()) p.putString("d1", obj["di1"].as<const char*>());
if (obj["di2"].is<const char*>()) p.putString("d2", obj["di2"].as<const char*>());
p.end();
Serial.println("[NVS] Noms sauvegardés");
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerNoms);
// --- API sleep / config ---
server.on("/api/sleep", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getSleepConfigJson(json);
r->send(200, "application/json", json);
});
auto *handlerSleep = new AsyncCallbackJsonWebHandler("/api/sleep",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
bool actif = obj["actif"] | false;
uint32_t inv = obj["intervalle"] | 600u;
float seuil = obj["seuil"] | 2.0f;
bool ok = setSleepConfig(actif, inv, seuil);
Serial.printf("[WEB] Sleep config — actif:%d intervalle:%ds seuil:%.1fV → %s\n",
actif, inv, seuil, ok ? "ok" : "erreur");
r->send(ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerSleep);
// --- API configuration EPEVER ---
// GET /api/epever/config → lecture depuis l'Epever + retour JSON
server.on("/api/epever/config", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
bool ok = lireConfigEpever();
if (!ok) {
r->send(503, "application/json", "{\"ok\":false,\"erreur\":\"RS485 indisponible ou Modbus occupe\"}");
return;
}
getConfigJson(json);
r->send(200, "application/json", json);
});
// POST /api/epever/config → écriture vers l'Epever
auto *handlerConfigWrite = new AsyncCallbackJsonWebHandler("/api/epever/config",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
String erreur;
bool ok = ecrireConfigEpever(obj, erreur);
if (ok) {
r->send(200, "application/json", "{\"ok\":true}");
} else {
String body = "{\"ok\":false,\"erreur\":\"" + erreur + "\"}";
r->send(409, "application/json", body);
}
});
server.addHandler(handlerConfigWrite);
// GET /api/epever/config/saved → lecture sauvegarde LittleFS
server.on("/api/epever/config/saved", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getConfigSauvegardeJson(json);
r->send(200, "application/json", json);
});
// POST /api/epever/config/save → sauvegarde cache → LittleFS
server.on("/api/epever/config/save", HTTP_POST, [](AsyncWebServerRequest *r) {
bool ok = sauvegarderConfigJson();
r->send(ok ? 200 : 500, "application/json",
ok ? "{\"ok\":true}" : "{\"ok\":false,\"erreur\":\"Cache vide ou LittleFS inaccessible\"}");
});
// POST /api/epever/config/restore → LittleFS → Epever
server.on("/api/epever/config/restore", HTTP_POST, [](AsyncWebServerRequest *r) {
String erreur;
bool ok = restaurerConfigJson(erreur);
if (ok) {
r->send(200, "application/json", "{\"ok\":true}");
} else {
String body = "{\"ok\":false,\"erreur\":\"" + erreur + "\"}";
r->send(409, "application/json", body);
}
});
// --- API intervalles Modbus ---
server.on("/api/modbus", HTTP_GET, [](AsyncWebServerRequest *r) {
uint32_t jour, nuit;
getIntervallesModbus(jour, nuit);
JsonDocument doc;
doc["jour"] = jour;
doc["nuit"] = nuit;
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerModbus = new AsyncCallbackJsonWebHandler("/api/modbus",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
uint32_t jour = obj["jour"] | 5000u;
uint32_t nuit = obj["nuit"] | 30000u;
jour = constrain(jour, 1000u, 60000u);
nuit = constrain(nuit, 5000u, 300000u);
setIntervallesModbus(jour, nuit);
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerModbus);
// --- API WiFi ---
// Statut complet AP + STA
server.on("/api/wifi/status", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getWifiStatusJson(json);
r->send(200, "application/json", json);
});
// Scan réseaux (synchrone ~3-5s)
server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
scannerReseauxJson(json);
r->send(200, "application/json", json);
});
// Connexion à un réseau WiFi existant
auto *handlerWifiSta = new AsyncCallbackJsonWebHandler("/api/wifi/sta",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
const char *ssid = obj["ssid"] | "";
const char *pass = obj["pass"] | "";
if (!ssid || strlen(ssid) == 0) {
r->send(400, "application/json", "{\"ok\":false,\"erreur\":\"SSID manquant\"}");
return;
}
if (strlen(ssid) > 32 || strlen(pass) > 64) {
r->send(400, "application/json", "{\"ok\":false,\"erreur\":\"SSID ou mot de passe trop long\"}");
return;
}
connecterWifiSTA(ssid, pass);
r->send(200, "application/json", "{\"ok\":true}");
});
server.addHandler(handlerWifiSta);
// Oublier le réseau STA (retour AP seul)
server.on("/api/wifi/sta/disconnect", HTTP_POST, [](AsyncWebServerRequest *r) {
deconnecterWifiSTA();
r->send(200, "application/json", "{\"ok\":true}");
});
// Hostname mDNS
server.on("/api/mdns", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
doc["hostname"] = getMdnsHostname();
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
auto *handlerMdns = new AsyncCallbackJsonWebHandler("/api/mdns",
[](AsyncWebServerRequest *r, JsonVariant &json) {
const char *nom = json["hostname"] | "";
bool ok = setMdnsHostname(nom);
r->send(ok ? 200 : 400, "application/json",
ok ? "{\"ok\":true}" : "{\"ok\":false,\"erreur\":\"Nom invalide (alphanum + tirets, 1-63 car.)\"}");
});
server.addHandler(handlerMdns);
// --- API WireGuard ---
server.on("/api/wireguard", HTTP_GET, [](AsyncWebServerRequest *r) {
String json;
getWireGuardJson(json);
r->send(200, "application/json", json);
});
auto *handlerWg = new AsyncCallbackJsonWebHandler("/api/wireguard",
[](AsyncWebServerRequest *r, JsonVariant &json) {
JsonObject obj = json.as<JsonObject>();
bool enabled = obj["enabled"] | false;
const char* prv = obj["privkey"] | "";
const char* pub = obj["pubkey"] | "";
const char* psk = obj["psk"] | "";
const char* ep = obj["endpoint"] | "";
uint16_t port = obj["port"] | 51820u;
const char* ip = obj["localip"] | "";
uint16_t ka = obj["keepalive"] | 25u;
if (!prv[0] || !pub[0] || !ep[0] || !ip[0]) {
r->send(400, "application/json", "{\"ok\":false,\"erreur\":\"Champs obligatoires manquants\"}");
return;
}
bool ok = setWireGuardConfig(enabled, prv, pub, psk, ep, port, ip, ka);
r->send(ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"ok\":false}");
});
server.addHandler(handlerWg);
// Compat : retourne info AP (SSID + IP)
server.on("/api/wifi", HTTP_GET, [](AsyncWebServerRequest *r) {
JsonDocument doc;
doc["ssid"] = WIFI_SSID;
doc["password"] = WIFI_PASSWORD;
String json; serializeJson(doc, json);
r->send(200, "application/json", json);
});
// --- Captive portal --- iOS, Android, Windows détectent l'absence d'internet
// et ouvrent automatiquement le navigateur sur notre page principale.
auto redirect = [](AsyncWebServerRequest *r) {
r->redirect("http://192.168.4.1/");
};
// iOS / macOS
server.on("/hotspot-detect.html", HTTP_GET, redirect);
server.on("/library/test/success.html", HTTP_GET, redirect);
server.on("/canonical.html", HTTP_GET, redirect);
// Android
server.on("/generate_204", HTTP_GET, redirect);
server.on("/gen_204", HTTP_GET, redirect);
server.on("/connecttest.txt", HTTP_GET, redirect);
// Windows
server.on("/ncsi.txt", HTTP_GET, redirect);
server.on("/redirect", HTTP_GET, redirect);
server.on("/success.txt", HTTP_GET, redirect);
// --- Fichiers statiques depuis LittleFS ---
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
server.onNotFound([](AsyncWebServerRequest *r){
// Tout GET inconnu → portail captif (navigateur s'ouvre sur la page d'accueil)
if (r->method() == HTTP_GET) {
r->redirect("http://192.168.4.1/");
} else {
r->send(404, "text/plain", "Non trouvé");
}
});
server.begin();
Serial.println("Serveur web démarré sur http://192.168.4.1");
}
+283
View File
@@ -0,0 +1,283 @@
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <Arduino.h>
#include "config.h"
#include "wifi_ap.h"
static DNSServer dnsServer;
// --- mDNS ---
static String mdnsHostname = "pv"; // default → pv.local
static void chargerMdnsNVS() {
Preferences p;
p.begin("mdns", true);
mdnsHostname = p.getString("host", "pv");
p.end();
// nettoyer : uniquement alphanum + tirets, lowercase
String clean;
for (char c : mdnsHostname) {
c = tolower(c);
if (isalnum(c) || c == '-') clean += c;
}
if (clean.isEmpty()) clean = "pv";
mdnsHostname = clean;
}
static void demarrerMdns() {
if (MDNS.begin(mdnsHostname.c_str())) {
MDNS.addService("http", "tcp", 80);
Serial.printf("[mDNS] Accessible sur http://%s.local\n", mdnsHostname.c_str());
} else {
Serial.println("[mDNS] Échec démarrage");
}
}
// --- État STA ---
static bool staConfiguree = false;
static bool staConnectee = false;
static String staSSID;
static String staPass;
static unsigned long tDerniereTentative = 0;
static const unsigned long RECONNECT_INTERVAL_MS = 30000UL;
// ---------------------------------------------------------------------------
// NVS helpers
// ---------------------------------------------------------------------------
static void chargerCredentialsNVS() {
Preferences p;
p.begin("wifi_sta", true);
staSSID = p.getString("ssid", "");
staPass = p.getString("pass", "");
p.end();
staConfiguree = staSSID.length() > 0;
}
static void sauvegarderCredentialsNVS(const char *ssid, const char *pass) {
Preferences p;
p.begin("wifi_sta", false);
p.putString("ssid", ssid);
p.putString("pass", pass);
p.end();
}
static void effacerCredentialsNVS() {
Preferences p;
p.begin("wifi_sta", false);
p.remove("ssid");
p.remove("pass");
p.end();
}
// ---------------------------------------------------------------------------
// Événements WiFi
// ---------------------------------------------------------------------------
static void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
Serial.printf("[WiFi][AP] Client connecté — MAC %02X:%02X:%02X:%02X:%02X:%02X clients:%d\n",
info.wifi_ap_staconnected.mac[0], info.wifi_ap_staconnected.mac[1],
info.wifi_ap_staconnected.mac[2], info.wifi_ap_staconnected.mac[3],
info.wifi_ap_staconnected.mac[4], info.wifi_ap_staconnected.mac[5],
WiFi.softAPgetStationNum());
break;
case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
Serial.printf("[WiFi][AP] Client déconnecté — clients restants:%d\n",
WiFi.softAPgetStationNum());
break;
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
staConnectee = true;
Serial.printf("[WiFi][STA] Connecté — SSID:%s IP:%s RSSI:%d dBm\n",
staSSID.c_str(),
WiFi.localIP().toString().c_str(),
WiFi.RSSI());
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
if (staConnectee) {
staConnectee = false;
tDerniereTentative = millis();
Serial.printf("[WiFi][STA] Déconnecté de %s — nouvelle tentative dans %lus\n",
staSSID.c_str(), RECONNECT_INTERVAL_MS / 1000);
}
break;
default:
break;
}
}
// ---------------------------------------------------------------------------
// Démarrage
// ---------------------------------------------------------------------------
void demarrerWifi() {
chargerMdnsNVS();
WiFi.onEvent(onWifiEvent);
// AP + STA simultanés — l'AP est toujours actif comme filet de sécurité
WiFi.mode(WIFI_AP_STA);
WiFi.softAPConfig(WIFI_IP, WIFI_GATEWAY, WIFI_SUBNET);
if (strlen(WIFI_PASSWORD) > 0) {
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
} else {
WiFi.softAP(WIFI_SSID);
}
Serial.printf("[WiFi][AP] Démarré — SSID:%s IP:%s\n",
WIFI_SSID, WiFi.softAPIP().toString().c_str());
// Captive portal — tous les noms DNS → 192.168.4.1 pour les clients AP
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
dnsServer.start(53, "*", WIFI_IP);
demarrerMdns();
// Tentative de connexion STA si credentials sauvegardés
chargerCredentialsNVS();
if (staConfiguree) {
Serial.printf("[WiFi][STA] Tentative de connexion à %s…\n", staSSID.c_str());
WiFi.begin(staSSID.c_str(), staPass.c_str());
tDerniereTentative = millis();
}
}
// ---------------------------------------------------------------------------
// Boucle principale (remplace traiterDNS)
// ---------------------------------------------------------------------------
void gererWifi() {
dnsServer.processNextRequest();
// Reconnexion automatique STA si configuré et déconnecté
if (staConfiguree && !staConnectee) {
unsigned long maintenant = millis();
if ((maintenant - tDerniereTentative) >= RECONNECT_INTERVAL_MS) {
tDerniereTentative = maintenant;
Serial.printf("[WiFi][STA] Reconnexion à %s…\n", staSSID.c_str());
WiFi.disconnect(false);
WiFi.begin(staSSID.c_str(), staPass.c_str());
}
}
}
// ---------------------------------------------------------------------------
// API publique — connexion / déconnexion
// ---------------------------------------------------------------------------
void connecterWifiSTA(const char *ssid, const char *pass) {
staSSID = ssid;
staPass = pass;
staConfiguree = true;
staConnectee = false;
tDerniereTentative = millis();
sauvegarderCredentialsNVS(ssid, pass);
WiFi.disconnect(false);
delay(100);
WiFi.begin(ssid, pass);
Serial.printf("[WiFi][STA] Connexion lancée vers %s\n", ssid);
}
void deconnecterWifiSTA() {
staConfiguree = false;
staConnectee = false;
staSSID = "";
staPass = "";
effacerCredentialsNVS();
WiFi.disconnect(true);
Serial.println("[WiFi][STA] Credentials effacés, STA déconnectée");
}
// ---------------------------------------------------------------------------
// API publique — scan réseaux
// ---------------------------------------------------------------------------
void scannerReseauxJson(String &out) {
int n = WiFi.scanNetworks(false, false); // synchrone, pas de réseaux cachés
JsonDocument doc;
doc["ok"] = (n >= 0);
if (n < 0) {
doc["erreur"] = "Scan échoué";
serializeJson(doc, out);
return;
}
JsonArray arr = doc["networks"].to<JsonArray>();
for (int i = 0; i < n; i++) {
String s = WiFi.SSID(i);
if (s.isEmpty()) continue; // ignorer réseaux cachés
JsonObject net = arr.add<JsonObject>();
net["ssid"] = s;
net["rssi"] = WiFi.RSSI(i);
net["secured"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
WiFi.scanDelete();
serializeJson(doc, out);
}
// ---------------------------------------------------------------------------
// API publique — statut JSON
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// API publique — mDNS
// ---------------------------------------------------------------------------
String getMdnsHostname() {
return mdnsHostname;
}
bool setMdnsHostname(const char *nom) {
String clean;
for (const char *p = nom; *p; p++) {
char c = tolower(*p);
if (isalnum(c) || c == '-') clean += c;
}
if (clean.isEmpty() || clean.length() > 63) return false;
mdnsHostname = clean;
Preferences prefs;
prefs.begin("mdns", false);
prefs.putString("host", mdnsHostname);
prefs.end();
MDNS.end();
demarrerMdns();
return true;
}
void getWifiStatusJson(String &out) {
JsonDocument doc;
// AP
JsonObject ap = doc["ap"].to<JsonObject>();
ap["ssid"] = WIFI_SSID;
ap["ip"] = WiFi.softAPIP().toString();
ap["clients"] = WiFi.softAPgetStationNum();
// STA
JsonObject sta = doc["sta"].to<JsonObject>();
sta["configured"] = staConfiguree;
sta["connected"] = staConnectee;
sta["ssid"] = staSSID;
sta["ip"] = staConnectee ? WiFi.localIP().toString() : String("");
sta["rssi"] = staConnectee ? WiFi.RSSI() : 0;
doc["mode"] = staConnectee ? "AP+STA" : (staConfiguree ? "AP (STA tentative)" : "AP");
serializeJson(doc, out);
}
+181
View File
@@ -0,0 +1,181 @@
#include "wireguard_vpn.h"
#ifndef QEMU_BUILD
#include <WireGuard-ESP32.h>
#include <WiFi.h>
#include <Preferences.h>
#include <ArduinoJson.h>
static WireGuard wg;
static bool wgActif = false;
static bool wgConnecte = false;
static String wgPrivKey;
static String wgPubKey;
static String wgPsk;
static String wgEndpoint;
static uint16_t wgPort = 51820;
static String wgLocalIP;
static uint16_t wgKeepalive = 25;
static unsigned long tDerniereConnexion = 0;
static const unsigned long WG_RETRY_MS = 60000UL;
// Valeurs initiales — à renseigner via l'interface web (onglet Config > VPN WireGuard)
static const char* WG_DEFAULT_PRIVKEY = "";
static const char* WG_DEFAULT_PUBKEY = "";
static const char* WG_DEFAULT_PSK = "";
static const char* WG_DEFAULT_ENDPOINT = "";
static const uint16_t WG_DEFAULT_PORT = 51820;
static const char* WG_DEFAULT_LOCALIP = "";
static const uint16_t WG_DEFAULT_KEEPALIVE = 25;
static void chargerWgNVS() {
Preferences p;
p.begin("wireguard", true);
wgActif = p.getBool("enabled", false);
wgPrivKey = p.getString("privkey", "");
wgPubKey = p.getString("pubkey", "");
wgPsk = p.getString("psk", "");
wgEndpoint = p.getString("endpoint", "");
wgPort = p.getUShort("port", 51820);
wgLocalIP = p.getString("localip", "");
wgKeepalive = p.getUShort("keepalive", 25);
p.end();
}
static void ecrireDefauts() {
Preferences p;
p.begin("wireguard", false);
p.putBool("enabled", false);
p.putString("privkey", WG_DEFAULT_PRIVKEY);
p.putString("pubkey", WG_DEFAULT_PUBKEY);
p.putString("psk", WG_DEFAULT_PSK);
p.putString("endpoint", WG_DEFAULT_ENDPOINT);
p.putUShort("port", WG_DEFAULT_PORT);
p.putString("localip", WG_DEFAULT_LOCALIP);
p.putUShort("keepalive", WG_DEFAULT_KEEPALIVE);
p.end();
}
static void connecterWg() {
if (wgPrivKey.isEmpty() || wgPubKey.isEmpty() || wgEndpoint.isEmpty() || wgLocalIP.isEmpty()) {
Serial.println("[WireGuard] Config incomplète — connexion annulée");
return;
}
// Extraire l'IP (supprimer le préfixe /24 si présent)
String ipStr = wgLocalIP;
int slash = ipStr.indexOf('/');
if (slash > 0) ipStr = ipStr.substring(0, slash);
IPAddress ip;
if (!ip.fromString(ipStr)) {
Serial.printf("[WireGuard] IP locale invalide : %s\n", wgLocalIP.c_str());
return;
}
Serial.printf("[WireGuard] Connexion → %s:%u IP locale: %s\n",
wgEndpoint.c_str(), wgPort, ipStr.c_str());
bool ok = wg.begin(ip, wgPrivKey.c_str(), wgEndpoint.c_str(),
wgPubKey.c_str(), wgPort);
if (ok) {
wgConnecte = true;
Serial.println("[WireGuard] Tunnel établi");
} else {
wgConnecte = false;
tDerniereConnexion = millis();
Serial.println("[WireGuard] Échec connexion — nouvelle tentative dans 60s");
}
}
void initWireGuard() {
// Premier boot : écrire les défauts issus de kc868-a2.conf si NVS vide
{
Preferences p;
p.begin("wireguard", true);
bool existe = p.isKey("privkey");
p.end();
if (!existe) {
Serial.println("[WireGuard] Initialisation NVS avec les défauts du .conf");
ecrireDefauts();
}
}
chargerWgNVS();
Serial.printf("[WireGuard] %s — endpoint: %s:%u IP: %s\n",
wgActif ? "Activé" : "Désactivé",
wgEndpoint.c_str(), wgPort, wgLocalIP.c_str());
}
void gererWireGuard() {
if (!wgActif) return;
bool staOk = (WiFi.status() == WL_CONNECTED);
if (staOk && !wgConnecte) {
unsigned long maintenant = millis();
if (tDerniereConnexion == 0 || (maintenant - tDerniereConnexion) >= WG_RETRY_MS) {
tDerniereConnexion = maintenant;
connecterWg();
}
} else if (!staOk && wgConnecte) {
wg.end();
wgConnecte = false;
tDerniereConnexion = 0;
Serial.println("[WireGuard] STA perdue — tunnel coupé");
}
}
bool setWireGuardConfig(bool enabled, const char* privKey, const char* pubKey,
const char* psk, const char* endpoint, uint16_t port,
const char* localIP, uint16_t keepalive) {
Preferences p;
p.begin("wireguard", false);
p.putBool("enabled", enabled);
p.putString("privkey", privKey);
p.putString("pubkey", pubKey);
p.putString("psk", psk);
p.putString("endpoint", endpoint);
p.putUShort("port", port);
p.putString("localip", localIP);
p.putUShort("keepalive", keepalive);
p.end();
// Déconnecter le tunnel en cours si actif
if (wgConnecte) {
wg.end();
wgConnecte = false;
}
tDerniereConnexion = 0;
chargerWgNVS();
Serial.printf("[WireGuard] Config mise à jour — %s\n", wgActif ? "activé" : "désactivé");
return true;
}
void getWireGuardJson(String &out) {
JsonDocument doc;
doc["enabled"] = wgActif;
doc["connected"] = wgConnecte;
doc["privkey"] = wgPrivKey;
doc["pubkey"] = wgPubKey;
doc["psk"] = wgPsk;
doc["endpoint"] = wgEndpoint;
doc["port"] = wgPort;
doc["localip"] = wgLocalIP;
doc["keepalive"] = wgKeepalive;
serializeJson(doc, out);
}
#else
// --- Stubs QEMU ---
void initWireGuard() { Serial.println("[WireGuard] Désactivé (build QEMU)"); }
void gererWireGuard() {}
void getWireGuardJson(String &out) { out = "{\"enabled\":false,\"connected\":false}"; }
bool setWireGuardConfig(bool, const char*, const char*, const char*,
const char*, uint16_t, const char*, uint16_t) { return false; }
#endif