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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user