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,2 @@
|
||||
2026-05-09T10:51:51Z
|
||||
firmware: kc868_a2 — modbus RS485 fonctionnel (115200 bauds, lecture brute)
|
||||
@@ -0,0 +1,706 @@
|
||||
'use strict';
|
||||
|
||||
const REFRESH_MS = (parseInt(localStorage.getItem('refresh_ms') || '1000', 10));
|
||||
|
||||
// Noms configurables — chargés depuis /api/names au démarrage
|
||||
const noms = { relay1: 'Relais 1', relay2: 'Relais 2', di1: 'Entrée 1', di2: 'Entrée 2' };
|
||||
|
||||
async function chargerNoms() {
|
||||
try {
|
||||
const d = await (await fetch('/api/names')).json();
|
||||
if (d.relay1) noms.relay1 = d.relay1;
|
||||
if (d.relay2) noms.relay2 = d.relay2;
|
||||
if (d.di1) noms.di1 = d.di1;
|
||||
if (d.di2) noms.di2 = d.di2;
|
||||
} catch {}
|
||||
appliquerNoms();
|
||||
}
|
||||
|
||||
function appliquerNoms() {
|
||||
setText('label-relay1', noms.relay1);
|
||||
setText('label-relay2', noms.relay2);
|
||||
setText('label-di1', noms.di1);
|
||||
setText('label-di2', noms.di2);
|
||||
setText('cmd-label-r1', noms.relay1);
|
||||
setText('cmd-label-r2', noms.relay2);
|
||||
const r1 = document.getElementById('c-n-relay1'); if (r1) r1.value = noms.relay1;
|
||||
const r2 = document.getElementById('c-n-relay2'); if (r2) r2.value = noms.relay2;
|
||||
const d1 = document.getElementById('c-n-di1'); if (d1) d1.value = noms.di1;
|
||||
const d2 = document.getElementById('c-n-di2'); if (d2) d2.value = noms.di2;
|
||||
}
|
||||
|
||||
async function sauvegarderNoms() {
|
||||
const payload = {
|
||||
relay1: document.getElementById('c-n-relay1').value.trim() || 'Relais 1',
|
||||
relay2: document.getElementById('c-n-relay2').value.trim() || 'Relais 2',
|
||||
di1: document.getElementById('c-n-di1').value.trim() || 'Entrée 1',
|
||||
di2: document.getElementById('c-n-di2').value.trim() || 'Entrée 2',
|
||||
};
|
||||
await fetch('/api/names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// --- Navigation onglets ---
|
||||
function afficherOnglet(nom, bouton) {
|
||||
document.querySelectorAll('.onglet').forEach(s => s.classList.remove('actif'));
|
||||
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById(nom).classList.add('actif');
|
||||
bouton.classList.add('active');
|
||||
if (nom === 'regles') chargerRegles();
|
||||
if (nom === 'config') { chargerSleep(); chargerWifi(); chargerPrefsUI(); chargerModbus(); }
|
||||
if (nom === 'historique') chargerHistorique();
|
||||
if (nom === 'debug') chargerDebug();
|
||||
}
|
||||
|
||||
// --- Rafraîchissement de l'état ---
|
||||
async function rafraichir() {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const d = await res.json();
|
||||
mettreAJourUI(d);
|
||||
} catch {
|
||||
const b = document.getElementById('rs485-badge');
|
||||
b.textContent = 'Hors ligne';
|
||||
b.className = 'badge badge-err';
|
||||
}
|
||||
}
|
||||
|
||||
function mettreAJourUI(d) {
|
||||
// PV & batterie
|
||||
setText('battery', d.battery.toFixed(2) + ' V');
|
||||
setText('pv', d.pv.toFixed(2) + ' V');
|
||||
setText('pvCurrent', d.pvCurrent.toFixed(2) + ' A');
|
||||
setText('sun', d.sun ? '☀ Jour' : '🌙 Nuit');
|
||||
setText('epeverTime', d.epeverTime || '--');
|
||||
setText('epeverClockOk', d.epeverClockOk ? 'OK' : 'ERR');
|
||||
setText('header-clock', d.espClockOk ? d.espTime : (d.epeverTime || '--'));
|
||||
// Batterie détaillée
|
||||
setText('batSOC', d.batSOC + ' %');
|
||||
setText('batTemp', d.batTemperature.toFixed(1) + ' °C');
|
||||
setText('batStatut', ['Arrêt','Float','Boost','Égalisation'][d.batStatut] || '--');
|
||||
// Load & énergie
|
||||
setText('loadVoltage', d.loadVoltage.toFixed(2) + ' V');
|
||||
setText('loadCurrent', d.loadCurrent.toFixed(2) + ' A');
|
||||
setText('loadPower', d.loadPower.toFixed(1) + ' W');
|
||||
setText('energieGenJour', d.energieGenJour.toFixed(2) + ' kWh');
|
||||
setText('energieConJour', d.energieConJour.toFixed(2) + ' kWh');
|
||||
setText('energieGenTotal', d.energieGenTotal.toFixed(2) + ' kWh');
|
||||
setText('energieConTotal', d.energieConTotal.toFixed(2) + ' kWh');
|
||||
// Relais & boutons
|
||||
setRelais('relay1-etat', 'carte-relay1', 'led-r1', 'btn-r1-on', 'btn-r1-off', d.relay1);
|
||||
setRelais('relay2-etat', 'carte-relay2', 'led-r2', 'btn-r2-on', 'btn-r2-off', d.relay2);
|
||||
setBouton('di1-etat', d.di1);
|
||||
setBouton('di2-etat', d.di2);
|
||||
|
||||
const badge = document.getElementById('rs485-badge');
|
||||
badge.textContent = d.rs485_ok ? 'RS485 OK' : 'RS485 ERR';
|
||||
badge.className = 'badge ' + (d.rs485_ok ? 'badge-ok' : 'badge-err');
|
||||
|
||||
|
||||
document.getElementById('pied-page').textContent =
|
||||
'Mise à jour : ' + new Date().toLocaleTimeString('fr-FR');
|
||||
}
|
||||
|
||||
function setBouton(id, etat) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.textContent = etat ? '● Appuyé' : '○ Relâché';
|
||||
el.className = 'valeur ' + (etat ? 'val-on' : 'val-off');
|
||||
}
|
||||
|
||||
function setRelais(id, carteId, ledId, btnOnId, btnOffId, etat) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = etat ? '● ON' : '○ OFF'; el.className = 'valeur ' + (etat ? 'val-on' : 'val-off'); }
|
||||
|
||||
const carte = document.getElementById(carteId);
|
||||
if (carte) carte.classList.toggle('carte-on', etat);
|
||||
|
||||
const led = document.getElementById(ledId);
|
||||
if (led) led.className = 'led ' + (etat ? 'led-on' : 'led-off');
|
||||
|
||||
const btnOn = document.getElementById(btnOnId);
|
||||
const btnOff = document.getElementById(btnOffId);
|
||||
if (btnOn) btnOn.className = 'btn btn-vert' + (etat ? ' btn-glow-vert' : ' btn-dim');
|
||||
if (btnOff) btnOff.className = 'btn btn-rouge' + (!etat ? ' btn-glow-rouge' : ' btn-dim');
|
||||
}
|
||||
|
||||
function setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
// --- Relais / Mode ---
|
||||
async function relay(n, cmd) {
|
||||
const res = await fetch('/api/relay/' + n + '/' + cmd, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
afficherToast(d.err || 'Commande refusée');
|
||||
}
|
||||
rafraichir();
|
||||
}
|
||||
|
||||
function afficherToast(msg) {
|
||||
let t = document.getElementById('toast');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'toast';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
t.classList.add('visible');
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.remove('visible'), 2800);
|
||||
}
|
||||
|
||||
async function rebootESP() {
|
||||
if (!confirm('Redémarrer l\'ESP32 ?')) return;
|
||||
await fetch('/api/reboot', { method: 'POST' });
|
||||
afficherToast('Redémarrage en cours…');
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
}
|
||||
|
||||
// --- Règles ---
|
||||
async function chargerRegles() {
|
||||
try {
|
||||
const res = await fetch('/api/rules');
|
||||
const data = await res.json();
|
||||
afficherRegles(data);
|
||||
} catch {
|
||||
document.getElementById('liste-regles').innerHTML =
|
||||
'<p style="color:var(--muted);text-align:center">Erreur chargement</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function afficherRegles(regles) {
|
||||
const el = document.getElementById('liste-regles');
|
||||
if (!regles.length) {
|
||||
el.innerHTML = '<p style="color:var(--muted);text-align:center;padding:1rem">Aucune règle</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = regles.map(r => {
|
||||
const cond = construireDescription(r);
|
||||
const cls = r.enabled ? '' : ' regle-desactivee';
|
||||
return `<div class="regle-item${cls}">
|
||||
<div class="regle-desc">
|
||||
<div class="regle-id">Règle #${r.id}</div>
|
||||
${cond}
|
||||
</div>
|
||||
<button class="btn btn-sm" onclick="toggleRegle(${r.id})">${r.enabled ? 'OFF' : 'ON'}</button>
|
||||
<button class="btn btn-sm btn-rouge" onclick="supprimerRegle(${r.id})">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function construireDescription(r) {
|
||||
const dec = [];
|
||||
if (r.sun !== undefined) dec.push(r.sun ? '☀ Jour' : '🌙 Nuit');
|
||||
if (r.di1 !== undefined) dec.push('DI1 ' + (r.di1 ? 'fermé' : 'ouvert'));
|
||||
if (r.di2 !== undefined) dec.push('DI2 ' + (r.di2 ? 'fermé' : 'ouvert'));
|
||||
|
||||
const cond = [];
|
||||
if (r.battery_min) cond.push('Bat ≥ ' + r.battery_min + 'V');
|
||||
if (r.battery_max) cond.push('Bat ≤ ' + r.battery_max + 'V');
|
||||
if (r.pv_min) cond.push('PV ≥ ' + r.pv_min + 'V');
|
||||
if (r.pv_max) cond.push('PV ≤ ' + r.pv_max + 'V');
|
||||
|
||||
const extras = [];
|
||||
if (r.delay) extras.push('délai ' + r.delay + 's');
|
||||
if (r.hysteresis) extras.push('hyst. ±' + r.hysteresis + 'V');
|
||||
|
||||
const c = '<span style="color:var(--accent);font-size:0.72rem">▶</span>';
|
||||
const lines = [];
|
||||
if (dec.length) lines.push(c + ' <em>Si</em> ' + dec.join(' + '));
|
||||
if (cond.length) lines.push(c + ' <em>Et</em> ' + cond.join(' + '));
|
||||
lines.push(c + ' → Relais ' + r.relay + ' <strong>' + (r.state ? 'ON' : 'OFF') + '</strong>'
|
||||
+ (extras.length ? ' <span style="color:var(--muted);font-size:0.78rem">(' + extras.join(', ') + ')</span>' : ''));
|
||||
return lines.join('<br>');
|
||||
}
|
||||
|
||||
async function toggleRegle(id) {
|
||||
await fetch('/api/rules/toggle?id=' + id, { method: 'POST' });
|
||||
chargerRegles();
|
||||
}
|
||||
|
||||
async function supprimerRegle(id) {
|
||||
await fetch('/api/rules/delete?id=' + id, { method: 'POST' });
|
||||
chargerRegles();
|
||||
}
|
||||
|
||||
async function ajouterRegle() {
|
||||
const sun = document.getElementById('f-sun').value;
|
||||
const di1 = document.getElementById('f-di1').value;
|
||||
const di2 = document.getElementById('f-di2').value;
|
||||
const batMin = parseFloat(document.getElementById('f-batmin').value) || 0;
|
||||
const batMax = parseFloat(document.getElementById('f-batmax').value) || 0;
|
||||
const pvMin = parseFloat(document.getElementById('f-pvmin').value) || 0;
|
||||
const pvMax = parseFloat(document.getElementById('f-pvmax').value) || 0;
|
||||
const relay = parseInt(document.getElementById('f-relay').value);
|
||||
const state = document.getElementById('f-state').value === 'true';
|
||||
const delay = parseInt(document.getElementById('f-delay').value) || 0;
|
||||
const hyst = parseFloat(document.getElementById('f-hysteresis').value) || 0;
|
||||
|
||||
const regle = { enabled: true, relay, state, delay };
|
||||
if (sun !== '') regle.sun = sun === 'true';
|
||||
if (di1 !== '') regle.di1 = di1 === 'true';
|
||||
if (di2 !== '') regle.di2 = di2 === 'true';
|
||||
if (batMin > 0) regle.battery_min = batMin;
|
||||
if (batMax > 0) regle.battery_max = batMax;
|
||||
if (pvMin > 0) regle.pv_min = pvMin;
|
||||
if (pvMax > 0) regle.pv_max = pvMax;
|
||||
if (hyst > 0) regle.hysteresis = hyst;
|
||||
|
||||
const res = await fetch('/api/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(regle)
|
||||
});
|
||||
if (res.ok) chargerRegles();
|
||||
}
|
||||
|
||||
// --- Préférences UI (stockées en localStorage) ---
|
||||
function getRefreshMs() {
|
||||
return parseInt(localStorage.getItem('refresh_ms') || '1000', 10);
|
||||
}
|
||||
|
||||
function chargerPrefsUI() {
|
||||
const r = document.getElementById('c-refresh');
|
||||
if (r) r.value = Math.round(getRefreshMs() / 1000);
|
||||
const lp = document.getElementById('c-longpress2');
|
||||
if (lp) lp.value = getLongPressMs();
|
||||
}
|
||||
|
||||
function sauvegarderInterface() {
|
||||
const s = parseInt(document.getElementById('c-refresh').value) || 1;
|
||||
const lp = parseInt(document.getElementById('c-longpress2').value) || 500;
|
||||
localStorage.setItem('refresh_ms', Math.min(60000, Math.max(1000, s * 1000)));
|
||||
localStorage.setItem('longpress_ms', Math.min(3000, Math.max(200, lp)));
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// --- WiFi info ---
|
||||
async function chargerWifi() {
|
||||
try {
|
||||
const d = await (await fetch('/api/wifi')).json();
|
||||
setText('wifi-ssid', d.ssid || '--');
|
||||
setText('wifi-pwd', d.password || '(vide)');
|
||||
} catch { /* silencieux */ }
|
||||
}
|
||||
|
||||
// --- Sleep / Config ---
|
||||
async function chargerSleep() {
|
||||
try {
|
||||
const d = await (await fetch('/api/sleep')).json();
|
||||
document.getElementById('c-sleep-actif').value = String(d.actif);
|
||||
document.getElementById('c-sleep-intervalle').value = Math.round(d.intervalle / 60);
|
||||
document.getElementById('c-sleep-seuil').value = d.seuil;
|
||||
} catch { /* silencieux */ }
|
||||
}
|
||||
|
||||
async function sauvegarderSleep() {
|
||||
const actif = document.getElementById('c-sleep-actif').value === 'true';
|
||||
const intervalle = parseInt(document.getElementById('c-sleep-intervalle').value) * 60;
|
||||
const seuil = parseFloat(document.getElementById('c-sleep-seuil').value);
|
||||
await fetch('/api/sleep', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ actif, intervalle, seuil })
|
||||
});
|
||||
}
|
||||
|
||||
// --- Intervalles Modbus ---
|
||||
async function chargerModbus() {
|
||||
try {
|
||||
const d = await (await fetch('/api/modbus')).json();
|
||||
const j = document.getElementById('c-mb-jour'); if (j) j.value = Math.round(d.jour / 1000);
|
||||
const n = document.getElementById('c-mb-nuit'); if (n) n.value = Math.round(d.nuit / 1000);
|
||||
} catch { /* silencieux */ }
|
||||
}
|
||||
|
||||
async function sauvegarderModbus() {
|
||||
const jour = (parseInt(document.getElementById('c-mb-jour').value) || 5) * 1000;
|
||||
const nuit = (parseInt(document.getElementById('c-mb-nuit').value) || 30) * 1000;
|
||||
await fetch('/api/modbus', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jour, nuit })
|
||||
});
|
||||
afficherToast('Intervalles Modbus sauvegardés');
|
||||
}
|
||||
|
||||
// --- Horloge EPEVER ---
|
||||
function toDatetimeLocalValue(date) {
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
|
||||
'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
||||
}
|
||||
|
||||
function remplirHeureNavigateur() {
|
||||
const input = document.getElementById('c-epever-time');
|
||||
if (input) input.value = toDatetimeLocalValue(new Date());
|
||||
}
|
||||
|
||||
async function sauvegarderHeureEpever() {
|
||||
const input = document.getElementById('c-epever-time');
|
||||
if (!input || !input.value) {
|
||||
afficherToast('Date/heure manquante');
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date(input.value);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
afficherToast('Date/heure invalide');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
year: d.getFullYear(),
|
||||
month: d.getMonth() + 1,
|
||||
day: d.getDate(),
|
||||
hour: d.getHours(),
|
||||
minute: d.getMinutes(),
|
||||
second: d.getSeconds()
|
||||
};
|
||||
|
||||
const res = await fetch('/api/epever/time', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
afficherToast(res.ok ? 'Horloge EPEVER réglée' : 'Réglage refusé, réessaie dans quelques secondes');
|
||||
rafraichir();
|
||||
}
|
||||
|
||||
// --- Debug série / RS485 ---
|
||||
async function chargerDebug() {
|
||||
const consoleEl = document.getElementById('debug-console');
|
||||
const metaEl = document.getElementById('debug-meta');
|
||||
if (!consoleEl || !metaEl) return;
|
||||
|
||||
try {
|
||||
const d = await (await fetch('/api/debug/logs')).json();
|
||||
metaEl.textContent = d.lines.length + ' lignes en mémoire, compteur ' + d.count;
|
||||
consoleEl.textContent = d.lines.map(l => {
|
||||
const s = Math.floor((l.t || 0) / 1000);
|
||||
return '[' + s.toString().padStart(6, ' ') + 's] ' + l.m;
|
||||
}).join('\n') || 'Aucun message.';
|
||||
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||
} catch {
|
||||
metaEl.textContent = 'Erreur lecture journal';
|
||||
consoleEl.textContent = 'Impossible de lire /api/debug/logs';
|
||||
}
|
||||
}
|
||||
|
||||
async function viderDebug() {
|
||||
await fetch('/api/debug/clear', { method: 'POST' });
|
||||
chargerDebug();
|
||||
}
|
||||
|
||||
// --- Historique ---
|
||||
let histMode = 'hires';
|
||||
|
||||
function setHistMode(mode) {
|
||||
histMode = mode;
|
||||
document.getElementById('btn-hires').classList.toggle('active-mode', mode === 'hires');
|
||||
document.getElementById('btn-lores').classList.toggle('active-mode', mode === 'lores');
|
||||
chargerHistorique();
|
||||
}
|
||||
|
||||
async function chargerHistorique() {
|
||||
try {
|
||||
const url = histMode === 'hires' ? '/api/history/hires' : '/api/history';
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const d = await res.json();
|
||||
await chargerHistoriqueStatus(d);
|
||||
const ids = [
|
||||
{ id: 'chart-battery', data: d.b, couleur: '#00b894', unite: 'V' },
|
||||
{ id: 'chart-pv', data: d.p, couleur: '#e94560', unite: 'V' },
|
||||
{ id: 'chart-load', data: d.l, couleur: '#fdcb6e', unite: 'W' },
|
||||
{ id: 'chart-soc', data: d.s, couleur: '#74b9ff', unite: '%' },
|
||||
];
|
||||
ids.forEach(({ id, data, couleur, unite }) => {
|
||||
const canvas = document.getElementById(id);
|
||||
if (canvas) dessinerGraphe(canvas, data, couleur, unite, d.step);
|
||||
});
|
||||
afficherDerniersPoints(d);
|
||||
} catch (e) {
|
||||
setText('hist-debug', 'Erreur chargement historique: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function chargerHistoriqueStatus(hist) {
|
||||
try {
|
||||
const s = await (await fetch('/api/history/status')).json();
|
||||
hist = hist || {};
|
||||
const mode = hist.mode || histMode;
|
||||
const n = hist.n !== undefined ? hist.n : 0;
|
||||
const nbBat = hist.b ? hist.b.length : 0;
|
||||
const nbPv = hist.p ? hist.p.length : 0;
|
||||
const nbLoad = hist.l ? hist.l.length : 0;
|
||||
const nbSoc = hist.s ? hist.s.length : 0;
|
||||
const nextH = Math.ceil((s.next_hires_ms || 0) / 1000);
|
||||
const nextL = Math.ceil((s.next_lores_ms || 0) / 1000);
|
||||
setText('hist-debug',
|
||||
'Mode ' + mode +
|
||||
' | points affichés: ' + n +
|
||||
' | séries b/p/l/s: ' + nbBat + '/' + nbPv + '/' + nbLoad + '/' + nbSoc +
|
||||
' | hires: ' + s.hires_n + '/' + s.hires_max +
|
||||
' | lores: ' + s.lores_n + '/' + s.lores_max +
|
||||
' | acc: ' + s.acc_n +
|
||||
' | RS485: ' + (s.rs485_ok ? 'OK' : 'ERR') +
|
||||
' | prochain 1min: ' + nextH + 's' +
|
||||
' | prochain 5min: ' + nextL + 's'
|
||||
);
|
||||
} catch {
|
||||
setText('hist-debug', 'Debug historique indisponible');
|
||||
}
|
||||
}
|
||||
|
||||
function afficherDerniersPoints(hist) {
|
||||
const el = document.getElementById('hist-last');
|
||||
if (!el) return;
|
||||
|
||||
const b = (hist.b || []).map(Number);
|
||||
const p = (hist.p || []).map(Number);
|
||||
const l = (hist.l || []).map(Number);
|
||||
const s = (hist.s || []).map(Number);
|
||||
const n = Math.min(b.length, p.length, l.length, s.length);
|
||||
if (!n) {
|
||||
el.textContent = 'Aucun point historique reçu depuis l’API.';
|
||||
return;
|
||||
}
|
||||
|
||||
const debut = Math.max(0, n - 5);
|
||||
const lignes = [];
|
||||
for (let i = debut; i < n; i++) {
|
||||
lignes.push(
|
||||
'#' + (i + 1) +
|
||||
' bat=' + b[i].toFixed(2) + 'V' +
|
||||
' pv=' + p[i].toFixed(2) + 'V' +
|
||||
' load=' + l[i].toFixed(1) + 'W' +
|
||||
' soc=' + s[i].toFixed(0) + '%'
|
||||
);
|
||||
}
|
||||
el.textContent = 'Derniers points: ' + lignes.join(' | ');
|
||||
}
|
||||
|
||||
function dessinerGraphe(canvas, data, couleur, unite, stepSec) {
|
||||
stepSec = stepSec || 300;
|
||||
data = (data || []).map(Number).filter(Number.isFinite);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const pad = { top: 12, right: 8, bottom: 28, left: 38 };
|
||||
const w = W - pad.left - pad.right;
|
||||
const h = H - pad.top - pad.bottom;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
if (!data.length) {
|
||||
ctx.fillStyle = '#a0aec0';
|
||||
ctx.font = '14px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Pas encore de données', W / 2, H / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const marge = Math.max((max - min) * 0.15, unite === '%' ? 2 : 0.2);
|
||||
const yMin = min === max ? min - marge : min - marge;
|
||||
const yMax = min === max ? max + marge : max + marge;
|
||||
const range = yMax - yMin || 1;
|
||||
|
||||
// grille horizontale
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + (h / 4) * i;
|
||||
ctx.strokeStyle = '#0f3460';
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
|
||||
const val = yMax - (range / 4) * i;
|
||||
ctx.fillStyle = '#a0aec0';
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(val.toFixed(1) + unite, pad.left - 4, y);
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
const x = pad.left + w / 2;
|
||||
const y = pad.top + h - ((data[0] - yMin) / range) * h;
|
||||
ctx.fillStyle = couleur;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#a0aec0';
|
||||
ctx.font = '11px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(data[0].toFixed(1) + unite, x, Math.max(12, y - 8));
|
||||
return;
|
||||
}
|
||||
|
||||
// courbe
|
||||
ctx.strokeStyle = couleur;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
data.forEach((v, i) => {
|
||||
const x = pad.left + (i / (data.length - 1)) * w;
|
||||
const y = pad.top + h - ((v - yMin) / range) * h;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// remplissage sous la courbe
|
||||
ctx.lineTo(pad.left + w, pad.top + h);
|
||||
ctx.lineTo(pad.left, pad.top + h);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = couleur + '22';
|
||||
ctx.fill();
|
||||
|
||||
// labels temps (axe X — ~5 labels)
|
||||
const now = Date.now();
|
||||
const step = Math.max(1, Math.floor(data.length / 5));
|
||||
ctx.fillStyle = '#a0aec0';
|
||||
ctx.font = '10px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < data.length; i += step) {
|
||||
const x = pad.left + (i / (data.length - 1)) * w;
|
||||
const t = new Date(now - (data.length - 1 - i) * stepSec * 1000);
|
||||
ctx.fillText(
|
||||
t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
x, H - 5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Long press relais dashboard ---
|
||||
function getLongPressMs() {
|
||||
return parseInt(localStorage.getItem('longpress_ms') || '500', 10);
|
||||
}
|
||||
|
||||
function setupLongPress(carteId, relayNum) {
|
||||
const el = document.getElementById(carteId);
|
||||
if (!el) return;
|
||||
let timer = null;
|
||||
|
||||
const start = (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.add('press-hold');
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
el.classList.remove('press-hold');
|
||||
el.classList.add('press-done');
|
||||
setTimeout(() => el.classList.remove('press-done'), 500);
|
||||
const res = await fetch('/api/relay/' + relayNum + '/toggle', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
afficherToast(d.err || 'Commande refusée');
|
||||
} else {
|
||||
afficherToast((relayNum === 1 ? noms.relay1 : noms.relay2) + ' basculé et sauvegardé');
|
||||
}
|
||||
rafraichir();
|
||||
}, getLongPressMs());
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
el.classList.remove('press-hold');
|
||||
};
|
||||
|
||||
el.addEventListener('pointerdown', start);
|
||||
el.addEventListener('pointerup', cancel);
|
||||
el.addEventListener('pointerleave', cancel);
|
||||
el.addEventListener('contextmenu', e => e.preventDefault());
|
||||
}
|
||||
|
||||
function setupSunLongPress() {
|
||||
const el = document.getElementById('carte-sun');
|
||||
if (!el) return;
|
||||
let timer = null;
|
||||
|
||||
const start = (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.add('press-hold');
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
el.classList.remove('press-hold');
|
||||
ouvrirSunPopup();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
el.classList.remove('press-hold');
|
||||
};
|
||||
|
||||
el.addEventListener('pointerdown', start);
|
||||
el.addEventListener('pointerup', cancel);
|
||||
el.addEventListener('pointerleave', cancel);
|
||||
el.addEventListener('contextmenu', e => e.preventDefault());
|
||||
}
|
||||
|
||||
function formatSunHistoryTime(value) {
|
||||
if (!value || value.indexOf('uptime') === 0) return value || '--';
|
||||
const parts = value.split(' ');
|
||||
if (parts.length !== 2) return value;
|
||||
const d = parts[0].split('-');
|
||||
const t = parts[1].split(':');
|
||||
if (d.length !== 3 || t.length < 2) return value;
|
||||
return t[0] + ':' + t[1] + ' ' + d[2] + '/' + d[1];
|
||||
}
|
||||
|
||||
async function ouvrirSunPopup() {
|
||||
const modal = document.getElementById('sun-modal');
|
||||
const list = document.getElementById('sun-history-list');
|
||||
if (!modal || !list) return;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
list.textContent = 'Chargement...';
|
||||
|
||||
try {
|
||||
const d = await (await fetch('/api/sun/history')).json();
|
||||
const changes = d.changes || [];
|
||||
if (!changes.length) {
|
||||
list.innerHTML = '<div class="modal-empty">Aucun changement enregistré depuis le boot.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = changes.slice().reverse().map(c =>
|
||||
'<div class="modal-row"><span>' + formatSunHistoryTime(c.time) + '</span><strong>' + c.label + '</strong></div>'
|
||||
).join('');
|
||||
} catch {
|
||||
list.innerHTML = '<div class="modal-empty">Erreur lecture historique jour/nuit.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function fermerSunPopup() {
|
||||
const modal = document.getElementById('sun-modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// --- Démarrage ---
|
||||
chargerNoms();
|
||||
rafraichir();
|
||||
setInterval(rafraichir, REFRESH_MS);
|
||||
setInterval(() => {
|
||||
const debugEl = document.getElementById('debug');
|
||||
const debugActif = debugEl && debugEl.classList.contains('actif');
|
||||
if (debugActif) chargerDebug();
|
||||
}, 2000);
|
||||
setInterval(() => {
|
||||
const histEl = document.getElementById('historique');
|
||||
const histActif = histEl && histEl.classList.contains('actif');
|
||||
if (histActif) chargerHistorique();
|
||||
}, 5000);
|
||||
setupLongPress('carte-relay1', 1);
|
||||
setupLongPress('carte-relay2', 2);
|
||||
setupSunLongPress();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<!-- soleil -->
|
||||
<circle cx="16" cy="13" r="5" fill="#fdcb6e"/>
|
||||
<!-- rayons -->
|
||||
<g stroke="#fdcb6e" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="16" y1="4" x2="16" y2="6"/>
|
||||
<line x1="16" y1="20" x2="16" y2="22"/>
|
||||
<line x1="7" y1="13" x2="9" y2="13"/>
|
||||
<line x1="23" y1="13" x2="25" y2="13"/>
|
||||
<line x1="9.5" y1="6.5" x2="11" y2="8"/>
|
||||
<line x1="21" y1="18" x2="22.5" y2="19.5"/>
|
||||
<line x1="22.5" y1="6.5" x2="21" y2="8"/>
|
||||
<line x1="9.5" y1="19.5" x2="11" y2="18"/>
|
||||
</g>
|
||||
<!-- batterie -->
|
||||
<rect x="9" y="23" width="14" height="6" rx="1.5" fill="none" stroke="#00b894" stroke-width="1.5"/>
|
||||
<rect x="23" y="25" width="2" height="2" rx="0.5" fill="#00b894"/>
|
||||
<rect x="10.5" y="24.5" width="8" height="3" rx="1" fill="#00b894"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 912 B |
@@ -0,0 +1,535 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>KC868 Solaire</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-title">
|
||||
<h1>⚡ Contrôleur Solaire</h1>
|
||||
<span id="header-clock" class="header-clock">--</span>
|
||||
</div>
|
||||
<span id="rs485-badge" class="badge badge-err">RS485 --</span>
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<!-- Dashboard : grille 2×2 -->
|
||||
<button class="tab active" title="Dashboard" onclick="afficherOnglet('dashboard', this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Règles : liste à puces -->
|
||||
<button class="tab" title="Règles" onclick="afficherOnglet('regles', this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/>
|
||||
<circle cx="4.5" cy="6" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<circle cx="4.5" cy="12" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<circle cx="4.5" cy="18" r="1.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Config : engrenage -->
|
||||
<button class="tab" title="Configuration" onclick="afficherOnglet('config', this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Historique : courbe -->
|
||||
<button class="tab" title="Historique" onclick="afficherOnglet('historique', this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Debug : terminal -->
|
||||
<button class="tab" title="Debug" onclick="afficherOnglet('debug', this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<section id="dashboard" class="onglet actif">
|
||||
|
||||
<div class="dash-section">Relais <span class="dash-hint">appui 1,1s = toggle + save</span></div>
|
||||
<div class="grille">
|
||||
<div class="carte" id="carte-relay1">
|
||||
<div class="etiquette" id="label-relay1">Relais 1</div>
|
||||
<div class="valeur" id="relay1-etat">--</div>
|
||||
</div>
|
||||
<div class="carte" id="carte-relay2">
|
||||
<div class="etiquette" id="label-relay2">Relais 2</div>
|
||||
<div class="valeur" id="relay2-etat">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-section">Entrées</div>
|
||||
<div class="grille">
|
||||
<div class="carte">
|
||||
<div class="etiquette" id="label-di1">Entrée 1</div>
|
||||
<div class="valeur" id="di1-etat">--</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette" id="label-di2">Entrée 2</div>
|
||||
<div class="valeur" id="di2-etat">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-section">Solaire</div>
|
||||
<div class="grille grille-3">
|
||||
<div class="carte">
|
||||
<div class="etiquette">Tension PV</div>
|
||||
<div class="valeur" id="pv">-- V</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Courant PV</div>
|
||||
<div class="valeur" id="pvCurrent">-- A</div>
|
||||
</div>
|
||||
<div class="carte" id="carte-sun">
|
||||
<div class="etiquette">Ensoleillement</div>
|
||||
<div class="valeur" id="sun">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grille">
|
||||
<div class="carte">
|
||||
<div class="etiquette">Horloge EPEVER</div>
|
||||
<div class="valeur valeur-compacte" id="epeverTime">--</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">RTC EPEVER</div>
|
||||
<div class="valeur" id="epeverClockOk">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-section">Batterie</div>
|
||||
<div class="grille">
|
||||
<div class="carte">
|
||||
<div class="etiquette">Tension</div>
|
||||
<div class="valeur" id="battery">-- V</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">SOC</div>
|
||||
<div class="valeur" id="batSOC">-- %</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Statut</div>
|
||||
<div class="valeur" id="batStatut">--</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Température</div>
|
||||
<div class="valeur" id="batTemp">-- °C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-section">Sortie 12V EPEVER</div>
|
||||
<div class="grille grille-3">
|
||||
<div class="carte">
|
||||
<div class="etiquette">Tension load</div>
|
||||
<div class="valeur" id="loadVoltage">-- V</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Courant load</div>
|
||||
<div class="valeur" id="loadCurrent">-- A</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Puissance load</div>
|
||||
<div class="valeur" id="loadPower">-- W</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-section">Énergie</div>
|
||||
<div class="grille">
|
||||
<div class="carte">
|
||||
<div class="etiquette">Prod. jour</div>
|
||||
<div class="valeur" id="energieGenJour">-- kWh</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Conso. jour</div>
|
||||
<div class="valeur" id="energieConJour">-- kWh</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Prod. total</div>
|
||||
<div class="valeur" id="energieGenTotal">-- kWh</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<div class="etiquette">Conso. total</div>
|
||||
<div class="valeur" id="energieConTotal">-- kWh</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Règles -->
|
||||
<section id="regles" class="onglet">
|
||||
<p class="aide">Chaque règle surveille des conditions (ensoleillement, tension batterie) et commande automatiquement un relais. Un délai optionnel évite les basculements intempestifs. Les règles s'appliquent en parallèle de la commande manuelle.</p>
|
||||
|
||||
<!-- Liste des règles -->
|
||||
<div id="liste-regles"></div>
|
||||
|
||||
<!-- Formulaire ajout -->
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Ajouter une règle</div>
|
||||
|
||||
<div class="form-section-label">Déclencheur</div>
|
||||
<div class="form-ligne">
|
||||
<label>Soleil</label>
|
||||
<select id="f-sun">
|
||||
<option value="">Ignoré</option>
|
||||
<option value="true">Jour</option>
|
||||
<option value="false">Nuit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Entrée DI1</label>
|
||||
<select id="f-di1">
|
||||
<option value="">Ignoré</option>
|
||||
<option value="true">Fermé (ON)</option>
|
||||
<option value="false">Ouvert (OFF)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Entrée DI2</label>
|
||||
<select id="f-di2">
|
||||
<option value="">Ignoré</option>
|
||||
<option value="true">Fermé (ON)</option>
|
||||
<option value="false">Ouvert (OFF)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-section-label">Condition</div>
|
||||
<div class="form-ligne">
|
||||
<label>Batt. min (V)</label>
|
||||
<input type="number" id="f-batmin" step="0.1" min="0" max="30" placeholder="0 = ignoré">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Batt. max (V)</label>
|
||||
<input type="number" id="f-batmax" step="0.1" min="0" max="30" placeholder="0 = ignoré">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>PV min (V)</label>
|
||||
<input type="number" id="f-pvmin" step="0.1" min="0" max="200" placeholder="0 = ignoré">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>PV max (V)</label>
|
||||
<input type="number" id="f-pvmax" step="0.1" min="0" max="200" placeholder="0 = ignoré">
|
||||
</div>
|
||||
|
||||
<div class="form-section-label">Action</div>
|
||||
<div class="form-ligne">
|
||||
<label>Relais</label>
|
||||
<select id="f-relay">
|
||||
<option value="1">Relais 1</option>
|
||||
<option value="2">Relais 2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>État</label>
|
||||
<select id="f-state">
|
||||
<option value="true">ON</option>
|
||||
<option value="false">OFF</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Délai (s)</label>
|
||||
<input type="number" id="f-delay" min="0" value="0" placeholder="0 = immédiat">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Hystérésis (V)</label>
|
||||
<input type="number" id="f-hysteresis" step="0.1" min="0" max="5" value="0" placeholder="0 = désactivé">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primaire btn-plein" onclick="ajouterRegle()">Ajouter</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Config / Paramètres -->
|
||||
<section id="config" class="onglet">
|
||||
<p class="aide">Commande manuelle des relais, noms personnalisables, sleep, OTA et redémarrage. Un appui maintenu sur une carte relais du dashboard bascule et sauvegarde l'état.</p>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Commande manuelle</div>
|
||||
<div class="ligne-commande relay-row">
|
||||
<span class="label-cmd"><span id="cmd-label-r1">Relais 1</span> <span id="led-r1" class="led led-off"></span></span>
|
||||
<button id="btn-r1-on" class="btn btn-vert btn-dim" onclick="relay(1,'on')">ON</button>
|
||||
<button id="btn-r1-off" class="btn btn-rouge btn-glow-rouge" onclick="relay(1,'off')">OFF</button>
|
||||
</div>
|
||||
<div class="ligne-commande relay-row">
|
||||
<span class="label-cmd"><span id="cmd-label-r2">Relais 2</span> <span id="led-r2" class="led led-off"></span></span>
|
||||
<button id="btn-r2-on" class="btn btn-vert btn-dim" onclick="relay(2,'on')">ON</button>
|
||||
<button id="btn-r2-off" class="btn btn-rouge btn-glow-rouge" onclick="relay(2,'off')">OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Noms des relais et entrées</div>
|
||||
<div class="form-ligne">
|
||||
<label>Relais 1</label>
|
||||
<input type="text" id="c-n-relay1" maxlength="20" placeholder="Relais 1">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Relais 2</label>
|
||||
<input type="text" id="c-n-relay2" maxlength="20" placeholder="Relais 2">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Entrée 1</label>
|
||||
<input type="text" id="c-n-di1" maxlength="20" placeholder="Entrée 1">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Entrée 2</label>
|
||||
<input type="text" id="c-n-di2" maxlength="20" placeholder="Entrée 2">
|
||||
</div>
|
||||
<button class="btn btn-primaire btn-plein" onclick="sauvegarderNoms()">Enregistrer et recharger</button>
|
||||
</div>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Mode économie d'énergie (sleep)</div>
|
||||
<p class="aide">En mode sleep, l'ESP32 s'éteint entre deux cycles de mesure pour réduire la consommation. Il se réveille périodiquement, lit les données Modbus, évalue les règles, puis se rendort si la tension PV est inférieure au seuil (nuit détectée). En journée (PV > seuil), il reste actif en permanence.</p>
|
||||
<div class="form-ligne">
|
||||
<label>Activé</label>
|
||||
<select id="c-sleep-actif">
|
||||
<option value="false">Non</option>
|
||||
<option value="true">Oui</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Réveil (min)</label>
|
||||
<input type="number" id="c-sleep-intervalle" min="1" max="120" value="10">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Seuil PV (V)</label>
|
||||
<input type="number" id="c-sleep-seuil" step="0.5" min="0" max="10" value="2.0">
|
||||
</div>
|
||||
<button class="btn btn-primaire btn-plein" onclick="sauvegarderSleep()">Enregistrer</button>
|
||||
</div>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Interface</div>
|
||||
<div class="form-ligne">
|
||||
<label>Rafraîchissement (s)</label>
|
||||
<input type="number" id="c-refresh" min="1" max="60" step="1" value="1">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Appui long (ms)</label>
|
||||
<input type="number" id="c-longpress2" min="200" max="3000" step="100" value="500">
|
||||
</div>
|
||||
<button class="btn btn-primaire btn-plein" onclick="sauvegarderInterface()">Enregistrer et recharger</button>
|
||||
</div>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Intervalles Modbus</div>
|
||||
<p class="aide">En <strong>mode soleil</strong> (PV actif), les données sont lues fréquemment. En <strong>mode veille</strong> (nuit / PV absent), l'intervalle est plus long pour économiser l'énergie.</p>
|
||||
<div class="form-ligne">
|
||||
<label>Mode soleil (s)</label>
|
||||
<input type="number" id="c-mb-jour" min="1" max="60" step="1" value="5">
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Mode veille (s)</label>
|
||||
<input type="number" id="c-mb-nuit" min="5" max="300" step="5" value="30">
|
||||
</div>
|
||||
<button class="btn btn-primaire btn-plein" onclick="sauvegarderModbus()">Enregistrer</button>
|
||||
</div>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Horloge EPEVER</div>
|
||||
<p class="aide">L'ESP32 cale son horloge sur l'EPEVER au boot puis toutes les 6h. Utilise ce réglage si l'heure du MPPT est décalée.</p>
|
||||
<div class="form-ligne">
|
||||
<label>Date/heure</label>
|
||||
<input type="datetime-local" id="c-epever-time" step="1">
|
||||
</div>
|
||||
<button class="btn btn-primaire btn-plein" onclick="remplirHeureNavigateur()">Utiliser l'heure du navigateur</button>
|
||||
<button class="btn btn-vert btn-plein" onclick="sauvegarderHeureEpever()">Régler l'EPEVER</button>
|
||||
</div>
|
||||
|
||||
<div class="regle-form">
|
||||
<div class="form-titre">Connexion WiFi</div>
|
||||
<div class="form-ligne">
|
||||
<label>SSID</label>
|
||||
<span id="wifi-ssid" class="wifi-val">--</span>
|
||||
</div>
|
||||
<div class="form-ligne">
|
||||
<label>Mot de passe</label>
|
||||
<span id="wifi-pwd" class="wifi-val">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="regle-form" style="margin-top:0.75rem">
|
||||
<div class="form-titre">Mise à jour firmware (OTA)</div>
|
||||
<p class="ota-info">Identifiants : aucun requis</p>
|
||||
<a href="/update" class="btn btn-primaire btn-plein">Ouvrir l'interface OTA</a>
|
||||
</div>
|
||||
|
||||
<div class="regle-form" style="margin-top:0.75rem">
|
||||
<div class="form-titre">Exporter les données</div>
|
||||
<p class="aide">Historique basse résolution : jusqu'à 30h de mesures (pas 5 min). Fichier CSV importable dans Excel, LibreOffice ou Google Sheets.</p>
|
||||
<a href="/api/history/csv" download="historique.csv" class="btn btn-primaire btn-plein">Télécharger l'historique (CSV)</a>
|
||||
</div>
|
||||
|
||||
<div class="regle-form" style="margin-top:0.75rem">
|
||||
<div class="form-titre">Système</div>
|
||||
<button class="btn btn-rouge btn-plein" onclick="rebootESP()">Redémarrer l'ESP32</button>
|
||||
</div>
|
||||
|
||||
<img src="board.jpg" alt="KC868-A2 board" class="board-img">
|
||||
|
||||
<div class="regle-form rs485-info">
|
||||
<div class="form-titre">Raccordement RS485 — Epever Tracer 4210N</div>
|
||||
<p class="aide">Le contrôleur Epever utilise un connecteur <strong>RJ45 8P8C</strong> pour la communication RS485 (Modbus RTU, <strong>115200 bps</strong>, 8N1). Les signaux A et B sont doublés (pins 3&4 = B, pins 5&6 = A).<br>⚠ Ne jamais connecter les pins 1&2 (+7.5V) au KC868-A2.</p>
|
||||
<pre class="rs485-schema">
|
||||
Epever 4210N — RJ45 vue de face (languette vers le bas)
|
||||
|
||||
╔══════════════════════════════════╗
|
||||
║ ┌──────────────────────────┐ ║
|
||||
║ │ ╷ ╷ ╷ ╷ ╷ ╷ ╷ ╷ │ ║
|
||||
║ │ 1 2 3 4 5 6 7 8 │ ║
|
||||
║ └──────────────────────────┘ ║
|
||||
╚══════════════════════════════════╝
|
||||
│ │ │ │ │ │ │ │
|
||||
GRI ORA NOI ROU VER JAU BLE MAR
|
||||
+7V +7V B− B− A+ A+ GND GND
|
||||
⚠️ ⚠️
|
||||
│ │ │
|
||||
(au choix, ex: ROU / JAU / BLE)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
KC868-A2 : B− A+ GND
|
||||
</pre>
|
||||
<table class="rs485-table">
|
||||
<thead><tr><th>Pin</th><th>Couleur</th><th>Signal</th><th>KC868-A2</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td><span class="fil" style="background:#888">Gris</span></td><td>+7.5V ⚠</td><td>Ne pas connecter</td></tr>
|
||||
<tr><td>2</td><td><span class="fil" style="background:#f80">Orange</span></td><td>+7.5V ⚠</td><td>Ne pas connecter</td></tr>
|
||||
<tr><td>3</td><td><span class="fil" style="background:#222">Noir</span></td><td>RS-485-B</td><td rowspan="2">B−</td></tr>
|
||||
<tr><td>4</td><td><span class="fil" style="background:#e00">Rouge</span></td><td>RS-485-B</td></tr>
|
||||
<tr><td>5</td><td><span class="fil" style="background:#0a0">Vert</span></td><td>RS-485-A</td><td rowspan="2">A+</td></tr>
|
||||
<tr><td>6</td><td><span class="fil" style="background:#cc0;color:#333">Jaune</span></td><td>RS-485-A</td></tr>
|
||||
<tr><td>7</td><td><span class="fil" style="background:#00c">Bleu</span></td><td>GND</td><td rowspan="2">GND (optionnel)</td></tr>
|
||||
<tr><td>8</td><td><span class="fil" style="background:#6b3a2a">Marron</span></td><td>GND</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="aide" style="margin-top:0.5rem">⚠ Le port RS485 de l'Epever n'est pas isolé. Un module d'isolation RS485 est recommandé pour éviter les boucles de masse.</p>
|
||||
<p class="aide">🔋 <strong>Alimentation KC868-A2</strong> : nécessite <strong>12V DC</strong> — brancher directement sur la batterie du système solaire. Ne pas utiliser les pins 1&2 du RJ45 (+7.5V, courant trop faible).</p>
|
||||
</div>
|
||||
|
||||
<div class="regle-form rs485-info">
|
||||
<div class="form-titre">Raccordement des relais</div>
|
||||
<p class="aide">Chaque relais dispose de 3 bornes : <strong>COM</strong> (commun), <strong>NO</strong> (normalement ouvert) et <strong>NC</strong> (normalement fermé). Capacité max : <strong>10A / 250V AC</strong>.</p>
|
||||
<pre class="rs485-schema">
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ RELAIS HORS TENSION (OFF) │
|
||||
│ │
|
||||
│ COM ────● ○ NO (circuit ouvert) │
|
||||
│ COM ────●───● NC (circuit fermé) │
|
||||
└──────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ RELAIS ALIMENTÉ (ON) │
|
||||
│ │
|
||||
│ COM ────●───● NO (circuit fermé) │
|
||||
│ COM ────● ○ NC (circuit ouvert) │
|
||||
└──────────────────────────────────────────────┘
|
||||
</pre>
|
||||
<table class="rs485-table">
|
||||
<thead><tr><th>Contact</th><th>Relais OFF</th><th>Relais ON</th><th>Usage typique</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>NO</td><td>Ouvert</td><td>Fermé</td><td>Charge OFF par défaut</td></tr>
|
||||
<tr><td>NC</td><td>Fermé</td><td>Ouvert</td><td>Charge ON par défaut</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="form-titre" style="margin-top:1rem">Exemple : commande d'une lampe 230V</div>
|
||||
<p class="aide">⚡ Travaux sur le 230V : couper le disjoncteur avant toute intervention. La tension 230V est dangereuse et potentiellement mortelle.</p>
|
||||
<pre class="rs485-schema">
|
||||
Utilisation du contact NO (lampe éteinte par défaut)
|
||||
|
||||
Tableau Bornier relais KC868-A2 Lampe
|
||||
électrique ┌───────────────────────────┐
|
||||
│ │
|
||||
Phase (L) ───►│ COM NO ►───┼──── Lampe ──┐
|
||||
│ NC │ │
|
||||
└───────────────────────────┘ │
|
||||
Neutre (N) ──────────────────────────────────────────────┘
|
||||
|
||||
Relais OFF → NO ouvert → lampe ÉTEINTE
|
||||
Relais ON → NO fermé → lampe ALLUMÉE
|
||||
|
||||
|
||||
Utilisation du contact NC (lampe allumée par défaut)
|
||||
|
||||
Tableau Bornier relais KC868-A2 Lampe
|
||||
électrique ┌───────────────────────────┐
|
||||
│ │
|
||||
Phase (L) ───►│ COM NC ►───┼──── Lampe ──┐
|
||||
│ NO │ │
|
||||
└───────────────────────────┘ │
|
||||
Neutre (N) ──────────────────────────────────────────────┘
|
||||
|
||||
Relais OFF → NC fermé → lampe ALLUMÉE
|
||||
Relais ON → NC ouvert → lampe ÉTEINTE
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Historique -->
|
||||
<section id="historique" class="onglet">
|
||||
<p class="aide">Mode <strong>4h</strong> : un point par minute (RAM uniquement). Mode <strong>30h</strong> : moyenne sur 5 min, sauvegardée toutes les heures sur le système de fichiers.</p>
|
||||
<div class="hist-toggle">
|
||||
<button id="btn-hires" class="btn btn-primaire active-mode" onclick="setHistMode('hires')">4h</button>
|
||||
<button id="btn-lores" class="btn" onclick="setHistMode('lores')">30h</button>
|
||||
<button class="btn" onclick="chargerHistorique()">↻</button>
|
||||
</div>
|
||||
<div id="hist-debug" class="hist-debug">Historique en attente...</div>
|
||||
<div id="hist-last" class="hist-debug">Derniers points en attente...</div>
|
||||
<div class="graphe-conteneur">
|
||||
<div class="graphe-titre">Tension batterie (V)</div>
|
||||
<canvas id="chart-battery" width="600" height="150"></canvas>
|
||||
</div>
|
||||
<div class="graphe-conteneur">
|
||||
<div class="graphe-titre">Tension PV (V)</div>
|
||||
<canvas id="chart-pv" width="600" height="150"></canvas>
|
||||
</div>
|
||||
<div class="graphe-conteneur">
|
||||
<div class="graphe-titre">Puissance load (W)</div>
|
||||
<canvas id="chart-load" width="600" height="150"></canvas>
|
||||
</div>
|
||||
<div class="graphe-conteneur">
|
||||
<div class="graphe-titre">SOC batterie (%)</div>
|
||||
<canvas id="chart-soc" width="600" height="150"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Debug -->
|
||||
<section id="debug" class="onglet">
|
||||
<div class="debug-actions">
|
||||
<button class="btn btn-primaire" onclick="chargerDebug()">Rafraîchir</button>
|
||||
<button class="btn btn-rouge" onclick="viderDebug()">Vider</button>
|
||||
</div>
|
||||
<div class="debug-meta" id="debug-meta">Journal en attente</div>
|
||||
<pre id="debug-console" class="debug-console">Chargement...</pre>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
<div id="sun-modal" class="modal hidden">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">Changements Jour/Nuit</div>
|
||||
<div id="sun-history-list" class="modal-list">Chargement...</div>
|
||||
<button class="btn btn-primaire btn-plein" onclick="fermerSunPopup()">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer id="pied-page">En attente de données…</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,501 @@
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--carte: #0f3460;
|
||||
--accent: #e94560;
|
||||
--vert: #00b894;
|
||||
--rouge: #d63031;
|
||||
--texte: #eaeaea;
|
||||
--muted: #a0aec0;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--texte);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* --- En-tête --- */
|
||||
header {
|
||||
background: var(--surface);
|
||||
padding: 0.6rem 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--carte);
|
||||
}
|
||||
header h1 { font-size: 0.95rem; font-weight: 700; }
|
||||
.header-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.header-clock {
|
||||
display: block;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--muted);
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* --- Badges --- */
|
||||
.badge {
|
||||
padding: 0.2rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.badge-ok { background: var(--vert); color: #000; }
|
||||
.badge-err { background: var(--rouge); color: #fff; }
|
||||
|
||||
/* --- Navigation --- */
|
||||
nav {
|
||||
display: flex;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--carte);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Contenu principal --- */
|
||||
main { flex: 1; padding: 0.65rem; }
|
||||
|
||||
.onglet { display: none; }
|
||||
.onglet.actif { display: block; }
|
||||
|
||||
/* --- Dashboard --- */
|
||||
.grille {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.grille-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.carte {
|
||||
background: var(--carte);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.45rem 0.35rem;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s, transform 0.1s;
|
||||
}
|
||||
.carte-on { border-color: var(--vert); }
|
||||
|
||||
/* Long press feedback — user-select uniquement sur les cartes relais */
|
||||
#carte-relay1, #carte-relay2 {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.press-hold {
|
||||
border-color: var(--accent) !important;
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s, border-color 0.1s !important;
|
||||
}
|
||||
@keyframes flash-save {
|
||||
0% { background: var(--accent); }
|
||||
100% { background: var(--carte); }
|
||||
}
|
||||
.press-done { animation: flash-save 0.5s ease-out forwards; }
|
||||
|
||||
.etiquette {
|
||||
font-size: 0.58rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.18rem;
|
||||
}
|
||||
.valeur {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.valeur-compacte {
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.val-on { color: var(--vert); }
|
||||
.val-off { color: var(--muted); }
|
||||
|
||||
.dash-section {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0 0.15rem;
|
||||
}
|
||||
.dash-hint {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
/* --- Commandes --- */
|
||||
.ligne-commande {
|
||||
background: var(--surface);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.label-cmd {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- Boutons --- */
|
||||
.btn {
|
||||
padding: 0.55rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--carte);
|
||||
color: var(--texte);
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.btn:active { opacity: 0.75; }
|
||||
.btn-actif { background: var(--accent); color: #fff; }
|
||||
.btn-vert { background: var(--vert); color: #000; }
|
||||
.btn-rouge { background: var(--rouge); color: #fff; }
|
||||
.btn-dim { opacity: 0.3; }
|
||||
.btn-glow-vert { box-shadow: 0 0 10px var(--vert); }
|
||||
.btn-glow-rouge { box-shadow: 0 0 10px var(--rouge); }
|
||||
|
||||
/* --- Voyant LED relais --- */
|
||||
.led {
|
||||
display: inline-block;
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.led-on { background: var(--vert); box-shadow: 0 0 7px var(--vert); }
|
||||
.led-off { background: #444; box-shadow: none; }
|
||||
.btn-primaire {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
margin-top: 1.2rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Règles --- */
|
||||
.regle-item {
|
||||
background: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.regle-desc { flex: 1; font-size: 0.85rem; line-height: 1.5; }
|
||||
.regle-id { font-size: 0.7rem; color: var(--muted); }
|
||||
.regle-desactivee { opacity: 0.45; }
|
||||
|
||||
.regle-form {
|
||||
background: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.form-titre {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.form-ligne {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-ligne label {
|
||||
width: 90px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.form-ligne select,
|
||||
.form-ligne input {
|
||||
flex: 1;
|
||||
background: var(--carte);
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
color: var(--texte);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-plein { display: block; width: 100%; margin-top: 0.75rem; text-align: center; }
|
||||
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.75rem; }
|
||||
|
||||
.form-section-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
border-bottom: 1px solid var(--carte);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* --- Toast notification --- */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(1rem);
|
||||
background: var(--rouge);
|
||||
color: #fff;
|
||||
padding: 0.55rem 1.2rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
white-space: nowrap;
|
||||
z-index: 999;
|
||||
}
|
||||
#toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* --- Modales --- */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.modal.hidden { display: none; }
|
||||
.modal-box {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--carte);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.modal-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.modal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
background: var(--carte);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.modal-row span {
|
||||
color: var(--muted);
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
.modal-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- Lignes relais désactivées en mode Auto --- */
|
||||
.row-disabled { opacity: 0.4; pointer-events: none; }
|
||||
|
||||
/* --- Textes d'aide onglets --- */
|
||||
.aide {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.aide strong { color: var(--texte); }
|
||||
|
||||
/* --- Board image --- */
|
||||
.board-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
height: auto;
|
||||
margin: 0.75rem auto 0;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--carte);
|
||||
}
|
||||
.ota-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Historique / Graphes --- */
|
||||
.hist-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.hist-toggle .btn { flex: 1; }
|
||||
.active-mode { background: var(--accent) !important; color: #fff !important; opacity: 1 !important; }
|
||||
.hist-debug {
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--carte);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.graphe-conteneur {
|
||||
background: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graphe-titre {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
/* --- Debug console --- */
|
||||
.debug-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.debug-actions .btn {
|
||||
flex: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
.debug-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.debug-console {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
max-height: 68vh;
|
||||
overflow: auto;
|
||||
background: #070b12;
|
||||
color: #d7f7df;
|
||||
border: 1px solid var(--carte);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* --- WiFi info --- */
|
||||
.wifi-val { font-family: monospace; font-size: 0.9rem; color: var(--texte); }
|
||||
|
||||
/* --- RS485 wiring info --- */
|
||||
.rs485-schema {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
color: var(--texte);
|
||||
background: var(--fond);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.rs485-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.rs485-table th, .rs485-table td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--carte);
|
||||
text-align: left;
|
||||
}
|
||||
.rs485-table th { background: var(--fond); color: var(--muted); }
|
||||
.fil {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* --- Pied de page --- */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--carte);
|
||||
}
|
||||
@@ -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