Files
gilles 14b3967590 Ajout publication MQTT (PubSubClient)
- Topics individuels par capteur sous un topic de base configurable (défaut: solar/)
  PV, batterie (tension/SOC/temp/statut), load, énergie, soleil, RS485, relais, entrées DI
- Abonnement relay/1/set et relay/2/set pour piloter les relais depuis MQTT
- Config NVS : serveur, port, user/pass optionnel, topic base, intervalle (défaut 30s)
- Reconnexion automatique toutes les 15s si broker inaccessible
- Publication immédiate après connexion et après changement de config
- Route GET/POST /api/mqtt + UI onglet Config avec liste des topics générée dynamiquement
- Stubs QEMU (#ifndef QEMU_BUILD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 07:13:05 +02:00

1158 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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(); chargerMqtt(); chargerWireGuard(); }
if (nom === 'historique') chargerHistorique();
if (nom === 'debug') chargerDebug();
if (nom === 'epever-config') lireConfigEpever();
}
// --- 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 ---
async function chargerWifi() {
try {
const d = await (await fetch('/api/wifi/status')).json();
// AP
setText('wifi-ap-ssid', d.ap?.ssid || '--');
setText('wifi-ap-ip', d.ap?.ip || '--');
setText('wifi-ap-clients', (d.ap?.clients ?? '--') + ' client(s)');
// Barre de statut
const bar = document.getElementById('wifi-status-bar');
if (bar) {
if (d.sta?.connected) {
bar.textContent = '✓ Connecté à ' + d.sta.ssid + ' — ' + d.sta.ip;
bar.className = 'ec-statusbar ec-ok';
} else if (d.sta?.configured) {
bar.textContent = '⏳ Tentative de connexion à ' + d.sta.ssid + '…';
bar.className = 'ec-statusbar';
} else {
bar.textContent = 'Mode AP uniquement — aucun réseau configuré';
bar.className = 'ec-statusbar';
}
}
// Champs STA pré-remplis
const ssidInput = document.getElementById('wifi-sta-ssid');
if (ssidInput && d.sta?.configured && !ssidInput.value)
ssidInput.value = d.sta.ssid || '';
// Bloc info STA connectée
const infoBloc = document.getElementById('wifi-sta-info');
if (infoBloc) {
if (d.sta?.connected) {
infoBloc.classList.remove('hidden');
setText('wifi-sta-ssid-cur', d.sta.ssid || '--');
setText('wifi-sta-ip', d.sta.ip || '--');
setText('wifi-sta-rssi', (d.sta.rssi ?? '--') + ' dBm');
} else {
infoBloc.classList.add('hidden');
}
}
// Bouton Oublier visible seulement si STA configurée
const btnOublier = document.getElementById('wifi-btn-oublier');
if (btnOublier)
btnOublier.style.display = d.sta?.configured ? '' : 'none';
} catch { /* silencieux */ }
chargerMdns();
}
async function scannerWifi() {
const btn = document.getElementById('wifi-btn-scan');
const list = document.getElementById('wifi-scan-list');
if (!btn || !list) return;
btn.disabled = true;
btn.textContent = '…';
list.innerHTML = '<div class="wifi-scan-item wifi-scan-wait">Scan en cours (3-5s)…</div>';
list.classList.remove('hidden');
try {
const res = await fetch('/api/wifi/scan');
const d = await res.json();
if (!d.ok || !d.networks?.length) {
list.innerHTML = '<div class="wifi-scan-item wifi-scan-wait">Aucun réseau trouvé</div>';
} else {
// tri par signal décroissant
d.networks.sort((a, b) => b.rssi - a.rssi);
list.innerHTML = d.networks.map(n => {
const bars = n.rssi >= -60 ? '▂▄▆█' : n.rssi >= -75 ? '▂▄▆·' : n.rssi >= -85 ? '▂▄··' : '▂···';
const lock = n.secured ? ' 🔒' : '';
return `<div class="wifi-scan-item" onclick="choisirReseau('${n.ssid.replace(/'/g,"\\'")}')">
<span class="wifi-scan-ssid">${n.ssid}${lock}</span>
<span class="wifi-scan-rssi">${bars} ${n.rssi}dBm</span>
</div>`;
}).join('');
}
} catch(e) {
list.innerHTML = '<div class="wifi-scan-item wifi-scan-wait">Erreur scan : ' + e.message + '</div>';
}
btn.disabled = false;
btn.textContent = 'Scanner';
}
function choisirReseau(ssid) {
const el = document.getElementById('wifi-sta-ssid');
if (el) el.value = ssid;
const list = document.getElementById('wifi-scan-list');
if (list) list.classList.add('hidden');
const passEl = document.getElementById('wifi-sta-pass');
if (passEl) passEl.focus();
}
async function connecterWifi() {
const ssid = (document.getElementById('wifi-sta-ssid')?.value || '').trim();
const pass = (document.getElementById('wifi-sta-pass')?.value || '');
if (!ssid) { afficherToast('SSID requis'); return; }
const bar = document.getElementById('wifi-status-bar');
if (bar) { bar.textContent = 'Connexion à ' + ssid + '…'; bar.className = 'ec-statusbar'; }
try {
const res = await fetch('/api/wifi/sta', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, pass })
});
const d = await res.json();
if (d.ok) {
afficherToast('Connexion lancée — vérifier le statut dans quelques secondes');
setTimeout(chargerWifi, 8000); // vérification auto après 8s
} else {
afficherToast('⚠ ' + (d.erreur || 'Erreur'));
}
} catch(e) {
afficherToast('Erreur réseau : ' + e.message);
}
}
async function chargerMdns() {
try {
const d = await (await fetch('/api/mdns')).json();
const el = document.getElementById('wifi-mdns-host');
if (el && d.hostname) el.value = d.hostname;
} catch { /* silencieux */ }
}
async function sauvegarderMdns() {
const el = document.getElementById('wifi-mdns-host');
const nom = (el?.value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
if (!nom) { afficherToast('Nom invalide'); return; }
try {
const res = await fetch('/api/mdns', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname: nom })
});
const d = await res.json();
if (d.ok) {
afficherToast('✓ mDNS → ' + nom + '.local');
if (el) el.value = nom;
} else {
afficherToast('⚠ ' + (d.erreur || 'Nom invalide'));
}
} catch(e) {
afficherToast('Erreur : ' + e.message);
}
}
async function oublierWifi() {
if (!confirm('Oublier le réseau WiFi configuré et revenir en mode AP uniquement ?')) return;
try {
const res = await fetch('/api/wifi/sta/disconnect', { method: 'POST' });
const d = await res.json();
if (d.ok) {
afficherToast('Réseau oublié — mode AP uniquement');
document.getElementById('wifi-sta-ssid').value = '';
document.getElementById('wifi-sta-pass').value = '';
chargerWifi();
}
} catch(e) {
afficherToast('Erreur : ' + e.message);
}
}
// --- 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 lAPI.';
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');
}
// --- MQTT ---
function mqttAfficherTopics(base) {
const info = document.getElementById('mqtt-topics-info');
const pub = document.getElementById('mqtt-topics-list');
const cmd = document.getElementById('mqtt-cmd-list');
if (!info || !pub || !cmd) return;
const b = base || 'solar';
const pubTopics = [
'pv/voltage', 'pv/current',
'battery/voltage', 'battery/soc', 'battery/temperature', 'battery/status',
'load/voltage', 'load/current', 'load/power',
'energy/generated/today', 'energy/generated/total',
'energy/consumed/today', 'energy/consumed/total',
'sun', 'rs485/ok', 'relay/1', 'relay/2', 'input/1', 'input/2'
];
pub.innerHTML = pubTopics.map(t => `<code>${b}/${t}</code>`).join('&nbsp;&nbsp;');
cmd.innerHTML = `<code>${b}/relay/1/set</code>&nbsp;&nbsp;<code>${b}/relay/2/set</code>` +
`<br><small style="color:var(--muted)">Valeurs acceptées : ON / OFF</small>`;
info.classList.remove('hidden');
}
async function chargerMqtt() {
try {
const d = await (await fetch('/api/mqtt')).json();
const bar = document.getElementById('mqtt-status-bar');
if (bar) {
if (d.enabled && d.connected) {
bar.textContent = '✓ Connecté — ' + d.server + ':' + d.port;
bar.className = 'ec-statusbar ec-ok';
} else if (d.enabled) {
bar.textContent = '⏳ Activé — en attente de connexion WiFi ou broker';
bar.className = 'ec-statusbar';
} else {
bar.textContent = 'Désactivé';
bar.className = 'ec-statusbar';
}
}
const s = id => document.getElementById(id);
s('mqtt-enabled').value = String(!!d.enabled);
if (s('mqtt-server')) s('mqtt-server').value = d.server || '192.168.1.36';
if (s('mqtt-port')) s('mqtt-port').value = d.port || 1883;
if (s('mqtt-user')) s('mqtt-user').value = d.user || '';
if (s('mqtt-pass')) s('mqtt-pass').value = d.pass || '';
if (s('mqtt-base')) s('mqtt-base').value = d.base || 'solar';
if (s('mqtt-interval')) s('mqtt-interval').value = d.interval || 30;
mqttAfficherTopics(d.base || 'solar');
} catch { /* silencieux */ }
}
async function sauvegarderMqtt() {
const g = id => (document.getElementById(id)?.value || '').trim();
const enabled = g('mqtt-enabled') === 'true';
const server = g('mqtt-server') || '192.168.1.36';
const port = parseInt(g('mqtt-port')) || 1883;
const user = g('mqtt-user');
const pass = g('mqtt-pass');
const base = g('mqtt-base') || 'solar';
const interval = parseInt(g('mqtt-interval')) || 30;
if (enabled && !server) { afficherToast('⚠ Adresse serveur requise'); return; }
try {
const res = await fetch('/api/mqtt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, server, port, user, pass, base, interval })
});
const d = await res.json();
if (d.ok) {
afficherToast(enabled ? '✓ MQTT activé — connexion en cours' : '✓ MQTT désactivé');
mqttAfficherTopics(base);
setTimeout(chargerMqtt, 3000);
} else {
afficherToast('⚠ Erreur sauvegarde MQTT');
}
} catch(e) {
afficherToast('Erreur : ' + e.message);
}
}
// --- WireGuard ---
async function chargerWireGuard() {
try {
const d = await (await fetch('/api/wireguard')).json();
const bar = document.getElementById('wg-status-bar');
if (bar) {
if (d.enabled && d.connected) {
bar.textContent = '✓ Tunnel actif — ' + d.localip;
bar.className = 'ec-statusbar ec-ok';
} else if (d.enabled) {
bar.textContent = '⏳ Activé — en attente de connexion STA WiFi';
bar.className = 'ec-statusbar';
} else {
bar.textContent = 'Désactivé';
bar.className = 'ec-statusbar';
}
}
const s = id => document.getElementById(id);
s('wg-enabled').value = String(!!d.enabled);
if (s('wg-privkey')) s('wg-privkey').value = d.privkey || '';
if (s('wg-pubkey')) s('wg-pubkey').value = d.pubkey || '';
if (s('wg-psk')) s('wg-psk').value = d.psk || '';
if (s('wg-endpoint')) s('wg-endpoint').value = d.endpoint || '';
if (s('wg-port')) s('wg-port').value = d.port || 51820;
if (s('wg-localip')) s('wg-localip').value = d.localip || '';
if (s('wg-keepalive')) s('wg-keepalive').value = d.keepalive || 25;
} catch { /* silencieux */ }
}
async function sauvegarderWireGuard() {
const g = id => (document.getElementById(id)?.value || '').trim();
const enabled = g('wg-enabled') === 'true';
const privkey = g('wg-privkey');
const pubkey = g('wg-pubkey');
const psk = g('wg-psk');
const endpoint = g('wg-endpoint');
const port = parseInt(g('wg-port')) || 51820;
const localip = g('wg-localip');
const keepalive = parseInt(g('wg-keepalive')) || 25;
if (!privkey || !pubkey || !endpoint || !localip) {
afficherToast('⚠ Clé privée, clé publique, endpoint et IP locale sont obligatoires');
return;
}
if (privkey.length !== 44 || pubkey.length !== 44) {
afficherToast('⚠ Les clés WireGuard doivent faire 44 caractères base64');
return;
}
try {
const res = await fetch('/api/wireguard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, privkey, pubkey, psk, endpoint, port, localip, keepalive })
});
const d = await res.json();
if (d.ok) {
afficherToast(enabled ? '✓ WireGuard activé — tunnel en cours d\'établissement' : '✓ WireGuard désactivé');
setTimeout(chargerWireGuard, 3000);
} else {
afficherToast('⚠ ' + (d.erreur || 'Erreur sauvegarde'));
}
} catch(e) {
afficherToast('Erreur : ' + e.message);
}
}
// --- Config EPEVER ---
const EC_KEYS = [
'battery_type', 'battery_capacity', 'temp_compensation',
'high_volt_disconnect', 'charging_limit', 'overvolt_reconnect',
'equalize_voltage', 'boost_voltage', 'float_voltage', 'boost_reconnect',
'low_reconnect', 'undervolt_warning', 'low_disconnect', 'discharge_limit',
'rated_voltage', 'equalize_duration', 'boost_duration', 'bat_discharge_soc'
];
function ecSetStatus(msg, ok) {
const el = document.getElementById('ec-status');
if (!el) return;
el.textContent = msg;
el.className = 'ec-statusbar ' + (ok === true ? 'ec-ok' : ok === false ? 'ec-err' : '');
}
function ecRemplirFormulaire(values) {
for (const key of EC_KEYS) {
const el = document.getElementById('ec-' + key);
if (!el || values[key] === undefined) continue;
el.value = values[key];
}
}
function ecLireFormulaire() {
const values = {};
for (const key of EC_KEYS) {
const el = document.getElementById('ec-' + key);
if (!el || el.value === '') continue;
values[key] = parseFloat(el.value);
}
return values;
}
async function lireConfigEpever() {
ecSetStatus('Lecture depuis l\'EPEVER en cours…');
try {
const res = await fetch('/api/epever/config');
const d = await res.json();
if (d.ok && d.values) {
ecRemplirFormulaire(d.values);
ecSetStatus('✓ Synchronisé — ' + new Date().toLocaleTimeString('fr-FR'), true);
} else {
ecSetStatus('⚠ ' + (d.erreur || 'Erreur RS485'), false);
}
} catch(e) {
ecSetStatus('✕ Erreur réseau : ' + e.message, false);
}
}
async function ecrireConfigEpever() {
if (!confirm(
'Confirmer l\'écriture des paramètres vers le régulateur EPEVER ?\n\n' +
'Une valeur incorrecte peut endommager la batterie.\n\n' +
'Vérifier les valeurs avant de confirmer.'
)) return;
ecSetStatus('Écriture en cours…');
const values = ecLireFormulaire();
try {
const res = await fetch('/api/epever/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
const d = await res.json();
if (d.ok) {
afficherToast('✓ Paramètres écrits dans l\'EPEVER');
ecSetStatus('✓ Écrit — ' + new Date().toLocaleTimeString('fr-FR'), true);
} else {
const msg = d.erreur || 'Erreur écriture';
afficherToast('⚠ ' + msg);
ecSetStatus('⚠ ' + msg, false);
}
} catch(e) {
ecSetStatus('✕ Erreur réseau : ' + e.message, false);
}
}
async function sauvegarderConfigEpever() {
try {
const res = await fetch('/api/epever/config/save', { method: 'POST' });
const d = await res.json();
afficherToast(d.ok ? '✓ Config sauvegardée dans LittleFS' : ('⚠ ' + (d.erreur || 'Erreur sauvegarde')));
} catch(e) {
afficherToast('✕ Erreur : ' + e.message);
}
}
async function restaurerConfigEpever() {
if (!confirm('Restaurer la configuration sauvegardée et l\'écrire dans l\'EPEVER ?')) return;
ecSetStatus('Restauration en cours…');
try {
const res = await fetch('/api/epever/config/restore', { method: 'POST' });
const d = await res.json();
if (d.ok) {
afficherToast('✓ Config restaurée');
await lireConfigEpever();
} else {
const msg = d.erreur || 'Erreur restauration';
afficherToast('⚠ ' + msg);
ecSetStatus('⚠ ' + msg, false);
}
} catch(e) {
ecSetStatus('✕ Erreur : ' + e.message, false);
}
}
async function exporterConfigEpever() {
try {
const res = await fetch('/api/epever/config/saved');
if (!res.ok) { afficherToast('Aucune config sauvegardée'); return; }
const text = await res.text();
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'epever_config.json';
a.click();
URL.revokeObjectURL(a.href);
} catch(e) {
afficherToast('Erreur export : ' + e.message);
}
}
async function importerConfigEpever(input) {
const file = input.files[0];
if (!file) return;
try {
const text = await file.text();
const obj = JSON.parse(text);
const vals = obj.values || obj;
ecRemplirFormulaire(vals);
afficherToast('Config importée — vérifier puis cliquer Écrire pour appliquer');
ecSetStatus('Fichier importé — non encore écrit dans l\'EPEVER');
} catch(e) {
afficherToast('Erreur import JSON : ' + e.message);
}
input.value = '';
}
// --- 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();