Files
gilles a8f0d6ccba 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>
2026-05-09 19:25:01 +02:00

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>