'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 = '

Erreur chargement

'; } } function afficherRegles(regles) { const el = document.getElementById('liste-regles'); if (!regles.length) { el.innerHTML = '

Aucune règle

'; return; } el.innerHTML = regles.map(r => { const cond = construireDescription(r); const cls = r.enabled ? '' : ' regle-desactivee'; return `
Règle #${r.id}
${cond}
`; }).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 = ''; const lines = []; if (dec.length) lines.push(c + ' Si ' + dec.join(' + ')); if (cond.length) lines.push(c + ' Et ' + cond.join(' + ')); lines.push(c + ' → Relais ' + r.relay + ' ' + (r.state ? 'ON' : 'OFF') + '' + (extras.length ? ' (' + extras.join(', ') + ')' : '')); return lines.join('
'); } 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 = ''; return; } list.innerHTML = changes.slice().reverse().map(c => '' ).join(''); } catch { list.innerHTML = ''; } } 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();