a8f0d6ccba
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>
510 lines
17 KiB
HTML
510 lines
17 KiB
HTML
<!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">
|
|
<title>KC868 Solaire — Prévisualisation</title>
|
|
<style>
|
|
/* === Données simulées — pas de connexion à la carte requise === */
|
|
|
|
: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;
|
|
max-width: 480px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
header {
|
|
background: var(--surface);
|
|
padding: 0.9rem 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid var(--carte);
|
|
}
|
|
header h1 { font-size: 1.05rem; font-weight: 700; }
|
|
|
|
.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; }
|
|
|
|
nav {
|
|
display: flex;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--carte);
|
|
overflow-x: auto;
|
|
}
|
|
.tab {
|
|
flex: 1;
|
|
min-width: 60px;
|
|
padding: 0.7rem 0.2rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
}
|
|
.tab.active {
|
|
color: var(--texte);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
|
|
main { flex: 1; padding: 1rem; }
|
|
|
|
.onglet { display: none; }
|
|
.onglet.actif { display: block; }
|
|
|
|
/* Dashboard */
|
|
.grille {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 0.75rem;
|
|
}
|
|
.carte {
|
|
background: var(--carte);
|
|
border-radius: 0.75rem;
|
|
padding: 1rem 0.75rem;
|
|
text-align: center;
|
|
}
|
|
.etiquette {
|
|
font-size: 0.7rem;
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.valeur { font-size: 1.4rem; font-weight: 700; }
|
|
.val-on { color: var(--vert); }
|
|
.val-off { color: var(--muted); }
|
|
|
|
/* Commandes */
|
|
.ligne-commande {
|
|
background: var(--surface);
|
|
border-radius: 0.75rem;
|
|
padding: 0.9rem 1rem;
|
|
margin-bottom: 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
}
|
|
.label-cmd { flex: 1; font-weight: 500; font-size: 0.95rem; }
|
|
|
|
/* Boutons */
|
|
.btn {
|
|
padding: 0.45rem 1.1rem;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
background: var(--carte);
|
|
color: var(--texte);
|
|
}
|
|
.btn-actif { background: var(--accent); color: #fff; }
|
|
.btn-vert { background: var(--vert); color: #000; }
|
|
.btn-rouge { background: var(--rouge); color: #fff; }
|
|
.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;
|
|
}
|
|
.btn-plein { display: block; width: 100%; margin-top: 0.75rem; text-align: center; }
|
|
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.75rem; }
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Firmware */
|
|
.firmware-bloc {
|
|
text-align: center;
|
|
padding: 2.5rem 1rem;
|
|
color: var(--muted);
|
|
line-height: 1.7;
|
|
}
|
|
.info-ota { margin-top: 0.5rem; font-size: 0.85rem; }
|
|
|
|
/* Historique */
|
|
.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; }
|
|
|
|
footer {
|
|
text-align: center;
|
|
padding: 0.45rem;
|
|
font-size: 0.7rem;
|
|
color: var(--muted);
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--carte);
|
|
}
|
|
|
|
.preview-banner {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
padding: 0.3rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="preview-banner">PRÉVISUALISATION — données simulées</div>
|
|
|
|
<header>
|
|
<h1>⚡ Contrôleur Solaire</h1>
|
|
<span class="badge badge-ok">RS485 OK</span>
|
|
</header>
|
|
|
|
<nav>
|
|
<button class="tab active" onclick="afficherOnglet('dashboard', this)">Dashboard</button>
|
|
<button class="tab" onclick="afficherOnglet('commandes', this)">Commandes</button>
|
|
<button class="tab" onclick="afficherOnglet('regles', this)">Règles</button>
|
|
<button class="tab" onclick="afficherOnglet('config', this)">Config</button>
|
|
<button class="tab" onclick="afficherOnglet('historique', this)">Historique</button>
|
|
<button class="tab" onclick="afficherOnglet('firmware', this)">Firmware</button>
|
|
</nav>
|
|
|
|
<main>
|
|
|
|
<!-- Dashboard -->
|
|
<section id="dashboard" class="onglet actif">
|
|
<div class="grille">
|
|
<div class="carte">
|
|
<div class="etiquette">Batterie</div>
|
|
<div class="valeur">13.45 V</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">Tension PV</div>
|
|
<div class="valeur">18.72 V</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">Courant PV</div>
|
|
<div class="valeur">4.20 A</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">Ensoleillement</div>
|
|
<div class="valeur">☀ Jour</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">Relais 1</div>
|
|
<div class="valeur val-on">● ON</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">Relais 2</div>
|
|
<div class="valeur val-off">○ OFF</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">DI1 (mode)</div>
|
|
<div class="valeur val-off">○ Relâché</div>
|
|
</div>
|
|
<div class="carte">
|
|
<div class="etiquette">DI2 (relais)</div>
|
|
<div class="valeur val-off">○ Relâché</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Commandes -->
|
|
<section id="commandes" class="onglet">
|
|
<div class="ligne-commande">
|
|
<span class="label-cmd">Mode</span>
|
|
<button class="btn btn-actif">Auto</button>
|
|
<button class="btn">Manuel</button>
|
|
</div>
|
|
<div class="ligne-commande">
|
|
<span class="label-cmd">Relais 1</span>
|
|
<button class="btn btn-vert">ON</button>
|
|
<button class="btn btn-rouge">OFF</button>
|
|
</div>
|
|
<div class="ligne-commande">
|
|
<span class="label-cmd">Relais 2</span>
|
|
<button class="btn btn-vert">ON</button>
|
|
<button class="btn btn-rouge">OFF</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Règles -->
|
|
<section id="regles" class="onglet">
|
|
|
|
<div class="regle-item">
|
|
<div class="regle-desc">
|
|
<div class="regle-id">Règle #1</div>
|
|
☀ Jour + Bat ≥ 13V → Relais 1 <strong>ON</strong>
|
|
</div>
|
|
<button class="btn btn-sm">OFF</button>
|
|
<button class="btn btn-sm btn-rouge">✕</button>
|
|
</div>
|
|
|
|
<div class="regle-item regle-desactivee">
|
|
<div class="regle-desc">
|
|
<div class="regle-id">Règle #2 (désactivée)</div>
|
|
🌙 Nuit → Relais 2 <strong>OFF</strong> (délai 3600s)
|
|
</div>
|
|
<button class="btn btn-sm btn-actif">ON</button>
|
|
<button class="btn btn-sm btn-rouge">✕</button>
|
|
</div>
|
|
|
|
<div class="regle-form">
|
|
<div class="form-titre">Ajouter une règle</div>
|
|
<div class="form-ligne">
|
|
<label>Soleil</label>
|
|
<select>
|
|
<option>Ignoré</option>
|
|
<option selected>Jour</option>
|
|
<option>Nuit</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Batt. min (V)</label>
|
|
<input type="number" placeholder="0 = ignoré" value="13">
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Batt. max (V)</label>
|
|
<input type="number" placeholder="0 = ignoré" value="0">
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Relais</label>
|
|
<select><option>Relais 1</option><option>Relais 2</option></select>
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Action</label>
|
|
<select><option>ON</option><option>OFF</option></select>
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Délai (s)</label>
|
|
<input type="number" value="0">
|
|
</div>
|
|
<button class="btn btn-primaire btn-plein">Ajouter</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Config -->
|
|
<section id="config" class="onglet">
|
|
<div class="regle-form">
|
|
<div class="form-titre">Mode économie d'énergie (sleep)</div>
|
|
<div class="form-ligne">
|
|
<label>Activé</label>
|
|
<select><option>Non</option><option>Oui</option></select>
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Réveil (min)</label>
|
|
<input type="number" value="10" min="1" max="120">
|
|
</div>
|
|
<div class="form-ligne">
|
|
<label>Seuil PV (V)</label>
|
|
<input type="number" value="2.0" step="0.5">
|
|
</div>
|
|
<button class="btn btn-primaire btn-plein">Enregistrer</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Historique -->
|
|
<section id="historique" class="onglet">
|
|
<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>
|
|
|
|
<!-- Firmware -->
|
|
<section id="firmware" class="onglet">
|
|
<div class="firmware-bloc">
|
|
<p>Mise à jour du firmware via l'interface OTA.</p>
|
|
<p class="info-ota">Identifiants : <strong>admin / solar123</strong></p>
|
|
<a href="#" class="btn btn-primaire">Ouvrir l'interface OTA</a>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<footer>Mise à jour : 14:32:07</footer>
|
|
|
|
<script>
|
|
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 === 'historique') afficherHistoriqueSimule();
|
|
}
|
|
|
|
// Simulation de 72 points (6h de données à 5 min)
|
|
function genSimule(base, amp, n) {
|
|
return Array.from({ length: n }, (_, i) =>
|
|
+(base + amp * Math.sin(i / n * Math.PI * 3) + (Math.random() - 0.5) * amp * 0.3).toFixed(2)
|
|
);
|
|
}
|
|
|
|
function afficherHistoriqueSimule() {
|
|
const n = 72;
|
|
const bat = genSimule(13.2, 0.6, n);
|
|
const pv = genSimule(14.0, 8.0, n);
|
|
const load = genSimule(25, 15.0, n);
|
|
const soc = genSimule(72, 8.0, n).map(v => Math.min(100, Math.max(0, Math.round(v))));
|
|
|
|
dessinerGraphe(document.getElementById('chart-battery'), bat, '#00b894', 'V');
|
|
dessinerGraphe(document.getElementById('chart-pv'), pv, '#e94560', 'V');
|
|
dessinerGraphe(document.getElementById('chart-load'), load, '#fdcb6e', 'W');
|
|
dessinerGraphe(document.getElementById('chart-soc'), soc, '#74b9ff', '%');
|
|
}
|
|
|
|
function dessinerGraphe(canvas, data, couleur, unite) {
|
|
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 || data.length < 2) {
|
|
ctx.fillStyle = '#a0aec0';
|
|
ctx.font = '14px system-ui';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('Pas encore de données (5 min)', W / 2, H / 2);
|
|
return;
|
|
}
|
|
|
|
const min = Math.min(...data);
|
|
const max = Math.max(...data);
|
|
const range = max - min || 1;
|
|
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = pad.top + (h / 4) * i;
|
|
ctx.strokeStyle = '#0f3460';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
|
|
const val = max - (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);
|
|
}
|
|
|
|
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 - min) / range) * h;
|
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
|
|
ctx.lineTo(pad.left + w, pad.top + h);
|
|
ctx.lineTo(pad.left, pad.top + h);
|
|
ctx.closePath();
|
|
ctx.fillStyle = couleur + '22';
|
|
ctx.fill();
|
|
|
|
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) * 5 * 60 * 1000);
|
|
ctx.fillText(
|
|
t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
|
x, H - 5
|
|
);
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|