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,4 @@
|
||||
#pragma once
|
||||
|
||||
void initBoutons();
|
||||
void gererBoutons();
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <IPAddress.h>
|
||||
|
||||
// --- WiFi point d'accès ---
|
||||
#define WIFI_SSID "kc868-a2"
|
||||
#define WIFI_PASSWORD "soleil12" // mot de passe WiFi AP
|
||||
#define WIFI_IP IPAddress(192, 168, 4, 1)
|
||||
#define WIFI_GATEWAY IPAddress(192, 168, 4, 1)
|
||||
#define WIFI_SUBNET IPAddress(255, 255, 255, 0)
|
||||
|
||||
// --- GPIO ---
|
||||
#define PIN_RELAY1 15
|
||||
#define PIN_RELAY2 2 // pin de strapping boot — doit être HIGH au démarrage
|
||||
#define PIN_RS485_TX 32
|
||||
#define PIN_RS485_RX 35 // input only
|
||||
#define PIN_DI1 36 // input only
|
||||
#define PIN_DI2 39 // input only
|
||||
|
||||
// --- OTA ---
|
||||
#define OTA_USER "admin"
|
||||
#define OTA_PASSWORD "solar123"
|
||||
|
||||
// --- Modbus ---
|
||||
#define MODBUS_ADRESSE 1 // adresse esclave Epever
|
||||
#define MODBUS_BAUDRATE 115200 // baudrate principal de l'Epever
|
||||
#define TIMEOUT_MODBUS 3000 // timeout réponse (ms) — doit être > délai interne lib (1s)
|
||||
#define MODBUS_DEBUG_BOOT 1 // sonde RS485 détaillée au démarrage
|
||||
#define MODBUS_DEBUG_RX_MAX 64 // octets max affichés en cas d'erreur
|
||||
|
||||
// --- Intervalles (ms) ---
|
||||
#define INTERVALLE_MODBUS 5000
|
||||
#define INTERVALLE_REGLES 1000
|
||||
#define DEBOUNCE_BOUTON 50
|
||||
@@ -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,7 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
void debugLogLine(const char *message);
|
||||
void debugLogf(const char *format, ...);
|
||||
void getDebugLogJson(String &out);
|
||||
void clearDebugLog();
|
||||
@@ -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,9 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
void initHistorique();
|
||||
void gererHistorique();
|
||||
void getHistoriqueJson(String &out); // lores : 30h, point toutes les 5 min
|
||||
void getHistoriqueHiresJson(String &out); // hires : 4h, point toutes les 1 min
|
||||
void getHistoriqueCsv(String &out); // export CSV lores pour téléchargement
|
||||
void getHistoriqueStatusJson(String &out); // debug compteurs internes
|
||||
@@ -0,0 +1,54 @@
|
||||
#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"
|
||||
|
||||
// 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();
|
||||
|
||||
debugLogf("Système prêt.");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
traiterDNS();
|
||||
gererOTA();
|
||||
gererBoutons();
|
||||
gererModbus();
|
||||
gererRegles();
|
||||
gererHistorique();
|
||||
gererSleep();
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
#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("-------------------");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
void initModbus();
|
||||
void gererModbus();
|
||||
void setIntervallesModbus(uint32_t jour_ms, uint32_t nuit_ms);
|
||||
void getIntervallesModbus(uint32_t &jour_ms, uint32_t &nuit_ms);
|
||||
bool reglerHorlogeEpever(uint16_t annee, uint8_t mois, uint8_t jour,
|
||||
uint8_t heure, uint8_t minute, uint8_t seconde);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
void demarrerOTA();
|
||||
void gererOTA();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
void initRegles();
|
||||
void gererRegles();
|
||||
|
||||
void reglesToJson(JsonArray arr);
|
||||
bool ajouterRegle(JsonObject obj);
|
||||
bool supprimerRegle(int id);
|
||||
bool toggleRegle(int id);
|
||||
@@ -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,18 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
// Appelé au tout début de setup() — entre en deep sleep si réveil timer + nuit
|
||||
void verifierEtDormirSiNuit();
|
||||
|
||||
// Appelé après montage de LittleFS
|
||||
void chargerConfigSleep();
|
||||
|
||||
// Restaure les relais depuis la mémoire RTC après un réveil
|
||||
void restaurerRelais();
|
||||
|
||||
// Appelé dans loop()
|
||||
void gererSleep();
|
||||
|
||||
// API REST
|
||||
void getSleepConfigJson(String &out);
|
||||
bool setSleepConfig(bool actif, uint32_t intervalle, float seuil);
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
struct SystemState {
|
||||
// --- PV ---
|
||||
float pv = 0.0f; // Tension PV (V)
|
||||
float pvCurrent = 0.0f; // Courant PV (A)
|
||||
|
||||
// --- Batterie ---
|
||||
float battery = 0.0f; // Tension (V)
|
||||
float batTemperature = 0.0f; // Température (°C)
|
||||
uint8_t batSOC = 0; // Charge restante (%)
|
||||
uint8_t batStatut = 0; // 0=arrêt 1=float 2=boost 3=égalisation
|
||||
bool batSousVoltage = false;
|
||||
bool batSurVoltage = false;
|
||||
|
||||
// --- Sortie de charge (load) ---
|
||||
float loadVoltage = 0.0f; // Tension (V)
|
||||
float loadCurrent = 0.0f; // Courant (A)
|
||||
float loadPower = 0.0f; // Puissance (W)
|
||||
|
||||
// --- Énergie (kWh, calculées par l'Epever) ---
|
||||
float energieGenJour = 0.0f; // Générée aujourd'hui
|
||||
float energieGenTotal = 0.0f; // Générée total
|
||||
float energieConJour = 0.0f; // Consommée aujourd'hui
|
||||
float energieConTotal = 0.0f; // Consommée total
|
||||
|
||||
// --- Ensoleillement ---
|
||||
bool sun = false; // true = jour
|
||||
bool sunHistoryValid = false;
|
||||
uint8_t sunHistoryCount = 0;
|
||||
uint8_t sunHistoryHead = 0;
|
||||
bool sunHistoryState[5] = {};
|
||||
char sunHistoryTime[5][20] = {};
|
||||
|
||||
// --- Horloge interne Epever ---
|
||||
bool epeverClockOk = false;
|
||||
uint8_t epeverSecond = 0;
|
||||
uint8_t epeverMinute = 0;
|
||||
uint8_t epeverHour = 0;
|
||||
uint8_t epeverDay = 0;
|
||||
uint8_t epeverMonth = 0;
|
||||
uint16_t epeverYear = 0;
|
||||
bool espClockOk = false;
|
||||
|
||||
// --- Relais ---
|
||||
bool relay1 = false;
|
||||
bool relay2 = false;
|
||||
|
||||
// --- Boutons DI ---
|
||||
bool di1 = false;
|
||||
bool di2 = false;
|
||||
|
||||
// --- Mode ---
|
||||
bool autoMode = true; // true = automatique, false = manuel
|
||||
|
||||
// --- Santé RS485 ---
|
||||
bool rs485_ok = false;
|
||||
unsigned long last_update = 0;
|
||||
};
|
||||
|
||||
extern SystemState state;
|
||||
@@ -0,0 +1,368 @@
|
||||
#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 "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 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 (SSID / mot de passe) ---
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <ESPAsyncWebServer.h>
|
||||
|
||||
extern AsyncWebServer server;
|
||||
|
||||
void demarrerWebserveur();
|
||||
void restaurerRelaisNVS();
|
||||
@@ -0,0 +1,53 @@
|
||||
#include <WiFi.h>
|
||||
#include <DNSServer.h>
|
||||
#include <Arduino.h>
|
||||
#include "config.h"
|
||||
|
||||
static DNSServer dnsServer;
|
||||
|
||||
static void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
|
||||
switch (event) {
|
||||
case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
|
||||
Serial.printf("[WiFi] 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] Client déconnecté — MAC %02X:%02X:%02X:%02X:%02X:%02X clients: %d\n",
|
||||
info.wifi_ap_stadisconnected.mac[0], info.wifi_ap_stadisconnected.mac[1],
|
||||
info.wifi_ap_stadisconnected.mac[2], info.wifi_ap_stadisconnected.mac[3],
|
||||
info.wifi_ap_stadisconnected.mac[4], info.wifi_ap_stadisconnected.mac[5],
|
||||
WiFi.softAPgetStationNum());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void demarrerWifi() {
|
||||
WiFi.onEvent(onWifiEvent);
|
||||
WiFi.mode(WIFI_AP);
|
||||
WiFi.softAPConfig(WIFI_IP, WIFI_GATEWAY, WIFI_SUBNET);
|
||||
|
||||
if (strlen(WIFI_PASSWORD) > 0) {
|
||||
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
|
||||
} else {
|
||||
WiFi.softAP(WIFI_SSID); // AP ouvert
|
||||
}
|
||||
|
||||
Serial.printf("[WiFi] AP démarré — SSID: %s IP: %s MAC: %s\n",
|
||||
WIFI_SSID,
|
||||
WiFi.softAPIP().toString().c_str(),
|
||||
WiFi.softAPmacAddress().c_str());
|
||||
|
||||
// Captive portal : tous les noms DNS → 192.168.4.1
|
||||
dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
|
||||
dnsServer.start(53, "*", WIFI_IP);
|
||||
Serial.println("[WiFi] DNS captive portal démarré (port 53)");
|
||||
}
|
||||
|
||||
void traiterDNS() {
|
||||
dnsServer.processNextRequest();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
void demarrerWifi();
|
||||
void traiterDNS();
|
||||
Reference in New Issue
Block a user