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:
@@ -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);
|
||||
}
|
||||
@@ -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é");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 += "}";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user