1453 lines
55 KiB
HTML
1453 lines
55 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>ESP Jardin — Monitoring</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" onerror="(function(){var w=document.querySelector('.chart-wrap');if(w){w.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--err);font-family:var(--font-terminal);font-size:13px;">Graphique indisponible (Chart.js non chargé)</div>';}}())"></script>
|
||
<style>
|
||
/* ── Variables thème dark ── */
|
||
:root[data-theme="dark"]{
|
||
--accent:#fe8019;--accent-soft:#d65d0e;--accent-tint:rgba(254,128,25,0.12);
|
||
--bg-1:#2a231d;--bg-2:#322920;--bg-3:#3c332a;--bg-4:#463c30;
|
||
--ink-1:#f2e5c7;--ink-2:#d5c4a1;--ink-3:#a89984;--ink-4:#6e6459;
|
||
--ok:#4dbb26;--warn:#fabd2f;--err:#fb4934;--blue:#3db0d1;--purple:#c882c8;
|
||
--border-1:rgba(255,255,255,0.06);--border-2:rgba(255,255,255,0.12);
|
||
--shadow-tile:0 1px 0 rgba(255,255,255,0.06) inset,0 4px 16px rgba(0,0,0,0.45);
|
||
--font-ui:'Inter',system-ui,sans-serif;
|
||
--font-mono:'JetBrains Mono',monospace;
|
||
--font-terminal:'Share Tech Mono',monospace;
|
||
}
|
||
/* ── Variables thème light ── */
|
||
:root[data-theme="light"]{
|
||
--accent:#af3a03;--accent-soft:#8a2d02;--accent-tint:rgba(175,58,3,0.10);
|
||
--bg-1:#f9f5f0;--bg-2:#f0ebe4;--bg-3:#e8e2da;--bg-4:#ddd6cc;
|
||
--ink-1:#3c3228;--ink-2:#5a4e42;--ink-3:#7c6e60;--ink-4:#a0907e;
|
||
--ok:#3c911c;--warn:#b57614;--err:#9d0006;--blue:#2d82a3;--purple:#8c468c;
|
||
--border-1:rgba(0,0,0,0.06);--border-2:rgba(0,0,0,0.14);
|
||
--shadow-tile:0 1px 0 rgba(255,255,255,0.8) inset,0 2px 8px rgba(0,0,0,0.15);
|
||
--font-ui:'Inter',system-ui,sans-serif;
|
||
--font-mono:'JetBrains Mono',monospace;
|
||
--font-terminal:'Share Tech Mono',monospace;
|
||
}
|
||
/* ── Reset ── */
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
html,body{height:100%;background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-size:14px;line-height:1.5}
|
||
/* ── Layout global ── */
|
||
body{display:flex;flex-direction:column;min-height:100vh}
|
||
/* ── Header ── */
|
||
header{
|
||
display:flex;align-items:center;gap:12px;
|
||
padding:10px 20px;
|
||
background:var(--bg-2);
|
||
border-bottom:1px solid var(--border-2);
|
||
flex-shrink:0;
|
||
}
|
||
.logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:.04em;white-space:nowrap}
|
||
.logo span{color:var(--ink-2);font-weight:400}
|
||
.header-gap{flex:1}
|
||
/* LED état WS */
|
||
.ws-led{
|
||
width:10px;height:10px;border-radius:50%;
|
||
background:var(--ink-4);flex-shrink:0;
|
||
transition:background .3s;
|
||
}
|
||
.ws-led.ok{background:var(--ok)}
|
||
.ws-led.err{background:var(--err);animation:pulse-led 1s ease-in-out infinite}
|
||
.ws-led.warn{background:var(--warn);animation:pulse-led .7s ease-in-out infinite}
|
||
@keyframes pulse-led{0%,100%{opacity:1}50%{opacity:.3}}
|
||
.rssi-badge{
|
||
font-family:var(--font-mono);font-size:11px;color:var(--ink-3);
|
||
background:var(--bg-3);border:1px solid var(--border-1);
|
||
padding:2px 8px;border-radius:6px;white-space:nowrap;
|
||
}
|
||
/* Boutons header */
|
||
.btn-icon{
|
||
background:var(--bg-3);border:1px solid var(--border-1);
|
||
color:var(--ink-2);cursor:pointer;border-radius:8px;
|
||
padding:6px 10px;font-size:13px;font-family:var(--font-ui);
|
||
transition:background .15s,color .15s;
|
||
}
|
||
.btn-icon:hover{background:var(--bg-4);color:var(--ink-1)}
|
||
/* ── Contenu principal ── */
|
||
.main-wrap{display:flex;flex:1;overflow:hidden;min-height:0}
|
||
main{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:16px;min-width:0}
|
||
/* ── Grille sondes ── */
|
||
.sondes-grid{
|
||
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
|
||
}
|
||
/* ── Carte sonde ── */
|
||
.sonde-card{
|
||
background:var(--bg-3);border-radius:11px;
|
||
border:1px solid var(--border-1);
|
||
box-shadow:var(--shadow-tile);
|
||
padding:16px 18px;
|
||
display:flex;flex-direction:column;gap:6px;
|
||
transition:border-color .3s;
|
||
}
|
||
.sonde-card.erreur{border-color:var(--err)}
|
||
.sonde-nom{
|
||
font-size:11px;font-weight:600;letter-spacing:.08em;
|
||
text-transform:uppercase;color:var(--ink-3);
|
||
}
|
||
.sonde-temp{
|
||
font-family:var(--font-mono);font-size:36px;font-weight:700;
|
||
color:var(--accent);line-height:1;
|
||
}
|
||
.sonde-temp .unite{font-size:18px;color:var(--ink-3);margin-left:2px}
|
||
.sonde-erreur-txt{
|
||
font-size:13px;color:var(--err);font-weight:500;
|
||
}
|
||
.sonde-status{
|
||
font-size:11px;color:var(--ink-4);margin-top:2px;
|
||
}
|
||
/* ── Carte graphique ── */
|
||
.chart-card{
|
||
background:var(--bg-3);border-radius:11px;
|
||
border:1px solid var(--border-1);
|
||
box-shadow:var(--shadow-tile);
|
||
padding:16px 18px;flex:1;
|
||
}
|
||
.card-titre{
|
||
font-size:11px;font-weight:600;letter-spacing:.08em;
|
||
text-transform:uppercase;color:var(--ink-3);margin-bottom:12px;
|
||
}
|
||
.chart-wrap{position:relative;height:220px}
|
||
/* ── Panneau droit ── */
|
||
.panel-right{
|
||
width:280px;flex-shrink:0;
|
||
background:var(--bg-2);
|
||
border-left:1px solid var(--border-2);
|
||
overflow-y:auto;padding:14px;
|
||
display:flex;flex-direction:column;gap:14px;
|
||
}
|
||
/* ── Tuile statut ── */
|
||
.stat-tile{
|
||
background:var(--bg-3);border-radius:10px;
|
||
border:1px solid var(--border-1);
|
||
box-shadow:var(--shadow-tile);
|
||
padding:12px 14px;
|
||
}
|
||
.stat-tile .tile-titre{
|
||
font-size:11px;font-weight:600;letter-spacing:.08em;
|
||
text-transform:uppercase;color:var(--ink-3);margin-bottom:8px;
|
||
}
|
||
.stat-row{
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
padding:3px 0;border-bottom:1px solid var(--border-1);
|
||
}
|
||
.stat-row:last-child{border-bottom:none}
|
||
.stat-label{font-size:12px;color:var(--ink-3)}
|
||
.stat-val{
|
||
font-family:var(--font-mono);font-size:12px;color:var(--ink-1);
|
||
}
|
||
.badge{
|
||
display:inline-flex;align-items:center;gap:4px;
|
||
font-size:11px;font-weight:600;border-radius:4px;
|
||
padding:1px 7px;
|
||
}
|
||
.badge.ok{background:rgba(77,187,38,.15);color:var(--ok)}
|
||
.badge.err{background:rgba(251,73,52,.15);color:var(--err)}
|
||
.badge.warn{background:rgba(250,189,47,.15);color:var(--warn)}
|
||
/* ── Formulaire config ── */
|
||
.form-tile{
|
||
background:var(--bg-3);border-radius:10px;
|
||
border:1px solid var(--border-1);
|
||
box-shadow:var(--shadow-tile);
|
||
padding:12px 14px;
|
||
}
|
||
.form-tile .tile-titre{
|
||
font-size:11px;font-weight:600;letter-spacing:.08em;
|
||
text-transform:uppercase;color:var(--ink-3);margin-bottom:10px;
|
||
}
|
||
.form-group{margin-bottom:8px}
|
||
label.fl{display:block;font-size:11px;color:var(--ink-3);margin-bottom:3px;letter-spacing:.04em}
|
||
input.fi{
|
||
width:100%;background:var(--bg-4);border:1px solid var(--border-2);
|
||
color:var(--ink-1);border-radius:6px;padding:5px 9px;
|
||
font-family:var(--font-mono);font-size:12px;
|
||
transition:border-color .15s;outline:none;
|
||
}
|
||
input.fi:focus{border-color:var(--accent)}
|
||
.btn-apply{
|
||
width:100%;background:var(--accent);color:#fff;
|
||
border:none;border-radius:8px;padding:7px 0;
|
||
font-family:var(--font-ui);font-size:13px;font-weight:600;
|
||
cursor:pointer;margin-top:4px;
|
||
transition:background .15s;
|
||
}
|
||
.btn-apply:hover{background:var(--accent-soft)}
|
||
.config-feedback{font-size:11px;color:var(--ok);margin-top:4px;min-height:14px}
|
||
/* ── Status bar ── */
|
||
.statusbar{
|
||
display:flex;align-items:center;gap:0;
|
||
background:var(--bg-2);border-top:1px solid var(--border-2);
|
||
padding:0;height:28px;flex-shrink:0;font-size:11px;
|
||
font-family:var(--font-terminal);color:var(--ink-3);overflow:hidden;
|
||
}
|
||
.sb-seg{
|
||
display:flex;align-items:center;gap:6px;
|
||
padding:0 14px;height:100%;border-right:1px solid var(--border-2);
|
||
white-space:nowrap;
|
||
}
|
||
.sb-seg:last-child{border-right:none}
|
||
.sb-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||
.sb-gap{flex:1}
|
||
.sb-clock{padding:0 14px;height:100%;display:flex;align-items:center;
|
||
font-family:var(--font-terminal);font-size:11px;color:var(--ink-3);}
|
||
/* ── Modal config (mobile) ── */
|
||
.modal-backdrop{
|
||
display:none;position:fixed;inset:0;
|
||
background:rgba(0,0,0,.65);z-index:100;
|
||
align-items:center;justify-content:center;
|
||
}
|
||
.modal-backdrop.open{display:flex}
|
||
.modal{
|
||
background:var(--bg-2);border:1px solid var(--border-2);
|
||
border-radius:12px;padding:20px;width:min(92vw,360px);
|
||
box-shadow:0 8px 40px rgba(0,0,0,.6);
|
||
}
|
||
.modal-header{
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
margin-bottom:14px;
|
||
}
|
||
.modal-titre{font-size:14px;font-weight:600;color:var(--ink-1)}
|
||
.btn-close{
|
||
background:none;border:none;cursor:pointer;
|
||
color:var(--ink-3);font-size:18px;line-height:1;padding:0 4px;
|
||
}
|
||
.btn-close:hover{color:var(--ink-1)}
|
||
/* ── Responsive ── */
|
||
@media(max-width:900px){
|
||
.panel-right{display:none}
|
||
}
|
||
@media(max-width:700px){
|
||
.sondes-grid{grid-template-columns:1fr}
|
||
header{padding:8px 14px;gap:8px}
|
||
.logo{font-size:15px}
|
||
main{padding:10px;gap:10px}
|
||
}
|
||
/* Bouton config visible seulement mobile */
|
||
.btn-cfg{display:none}
|
||
/* Scrollbar custom dark */
|
||
:root[data-theme="dark"] *::-webkit-scrollbar{width:6px;height:6px}
|
||
:root[data-theme="dark"] *::-webkit-scrollbar-track{background:var(--bg-2)}
|
||
:root[data-theme="dark"] *::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
|
||
/* ── Modal WiFi ── */
|
||
.wifi-modal-backdrop{
|
||
display:none;position:fixed;inset:0;
|
||
background:rgba(0,0,0,.7);z-index:200;
|
||
align-items:flex-start;justify-content:center;
|
||
padding-top:60px;overflow-y:auto;
|
||
}
|
||
.wifi-modal-backdrop.open{display:flex}
|
||
.wifi-modal{
|
||
background:var(--bg-2);border:1px solid var(--border-2);
|
||
border-radius:12px;padding:24px;width:min(96vw,480px);
|
||
box-shadow:0 8px 40px rgba(0,0,0,.6);
|
||
display:flex;flex-direction:column;gap:18px;
|
||
margin-bottom:60px;
|
||
}
|
||
.wifi-modal-header{
|
||
display:flex;justify-content:space-between;align-items:center;
|
||
}
|
||
.wifi-modal-titre{font-size:15px;font-weight:700;color:var(--ink-1);letter-spacing:.04em}
|
||
/* Bloc réseau actuel */
|
||
.wifi-current{
|
||
background:var(--bg-3);border-radius:10px;border:1px solid var(--border-1);
|
||
padding:14px 16px;display:flex;flex-direction:column;gap:6px;
|
||
}
|
||
.wifi-current-titre{
|
||
font-size:11px;font-weight:600;text-transform:uppercase;
|
||
letter-spacing:.08em;color:var(--ink-3);margin-bottom:4px;
|
||
}
|
||
.wifi-current-row{display:flex;justify-content:space-between;align-items:center;gap:8px;}
|
||
.wifi-current-label{font-size:12px;color:var(--ink-3);font-family:var(--font-ui);}
|
||
.wifi-current-val{font-family:var(--font-mono);font-size:13px;color:var(--ink-1);}
|
||
/* Liste réseaux */
|
||
.wifi-scan-header{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||
.wifi-scan-titre{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
|
||
.wifi-networks-list{
|
||
display:flex;flex-direction:column;gap:4px;
|
||
max-height:220px;overflow-y:auto;
|
||
background:var(--bg-3);border-radius:10px;border:1px solid var(--border-1);
|
||
padding:6px;
|
||
}
|
||
.wifi-net-item{
|
||
display:flex;align-items:center;gap:10px;
|
||
padding:8px 10px;border-radius:8px;cursor:pointer;
|
||
transition:background .15s;border:1px solid transparent;
|
||
}
|
||
.wifi-net-item:hover{background:var(--bg-4);border-color:var(--border-2)}
|
||
.wifi-net-ssid{flex:1;font-size:13px;color:var(--ink-1);font-family:var(--font-ui);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.wifi-net-signal{font-family:var(--font-terminal);font-size:14px;color:var(--blue);letter-spacing:-.05em;flex-shrink:0}
|
||
.wifi-net-lock{font-size:12px;color:var(--ink-3);flex-shrink:0}
|
||
.wifi-scan-msg{
|
||
font-size:12px;color:var(--ink-3);font-family:var(--font-terminal);
|
||
padding:12px;text-align:center;
|
||
}
|
||
/* Formulaire connexion */
|
||
.wifi-form-titre{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
|
||
.wifi-form{display:flex;flex-direction:column;gap:10px}
|
||
.wifi-field{display:flex;flex-direction:column;gap:4px}
|
||
.wifi-label{font-size:11px;color:var(--ink-3);letter-spacing:.04em}
|
||
.wifi-input{
|
||
background:var(--bg-3);border:1px solid var(--border-2);border-radius:8px;
|
||
color:var(--ink-1);font-family:var(--font-mono);font-size:13px;
|
||
padding:8px 12px;outline:none;width:100%;
|
||
transition:border-color .15s;
|
||
}
|
||
.wifi-input:focus{border-color:var(--accent)}
|
||
.wifi-pass-wrap{position:relative;display:flex;align-items:center}
|
||
.wifi-pass-wrap .wifi-input{padding-right:38px}
|
||
.wifi-pass-toggle{
|
||
position:absolute;right:8px;background:none;border:none;
|
||
cursor:pointer;color:var(--ink-3);font-size:16px;padding:2px;
|
||
line-height:1;transition:color .15s;
|
||
}
|
||
.wifi-pass-toggle:hover{color:var(--ink-1)}
|
||
.btn-wifi-save{
|
||
background:var(--accent);color:#1a1a1a;font-weight:700;
|
||
border:none;border-radius:9px;padding:10px 18px;
|
||
cursor:pointer;font-size:13px;font-family:var(--font-ui);
|
||
letter-spacing:.04em;transition:background .15s;margin-top:4px;
|
||
}
|
||
.btn-wifi-save:hover{background:var(--accent-soft);color:#fff}
|
||
.btn-wifi-save:disabled{background:var(--bg-4);color:var(--ink-4);cursor:not-allowed}
|
||
.wifi-feedback{
|
||
font-size:12px;font-family:var(--font-terminal);min-height:18px;
|
||
text-align:center;color:var(--ok);
|
||
}
|
||
.btn-scan{
|
||
background:var(--bg-4);border:1px solid var(--border-2);
|
||
color:var(--ink-2);cursor:pointer;border-radius:7px;
|
||
padding:5px 12px;font-size:12px;font-family:var(--font-ui);
|
||
transition:background .15s,color .15s;
|
||
}
|
||
.btn-scan:hover{background:var(--bg-3);color:var(--ink-1)}
|
||
.btn-scan:disabled{cursor:not-allowed;color:var(--ink-4)}
|
||
/* ── Modal paramètres ── */
|
||
.settings-modal-backdrop{
|
||
display:none;position:fixed;inset:0;
|
||
background:rgba(0,0,0,.72);z-index:180;
|
||
align-items:flex-start;justify-content:center;
|
||
padding:48px 12px;overflow-y:auto;
|
||
}
|
||
.settings-modal-backdrop.open{display:flex}
|
||
.settings-modal{
|
||
background:var(--bg-2);border:1px solid var(--border-2);
|
||
border-radius:12px;width:min(96vw,620px);
|
||
box-shadow:0 8px 40px rgba(0,0,0,.62);
|
||
padding:22px;display:flex;flex-direction:column;gap:16px;
|
||
}
|
||
.settings-header{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||
.settings-title{font-size:15px;font-weight:700;color:var(--ink-1);letter-spacing:.04em}
|
||
.settings-section{
|
||
background:var(--bg-3);border:1px solid var(--border-1);
|
||
border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px;
|
||
}
|
||
.settings-section-title{
|
||
font-size:11px;font-weight:600;text-transform:uppercase;
|
||
letter-spacing:.08em;color:var(--ink-3);
|
||
}
|
||
.settings-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
||
.ota-row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end}
|
||
.ota-file{
|
||
width:100%;background:var(--bg-4);border:1px solid var(--border-2);
|
||
color:var(--ink-1);border-radius:8px;padding:7px;
|
||
font-family:var(--font-mono);font-size:12px;
|
||
}
|
||
.ota-progress{
|
||
height:8px;background:var(--bg-4);border:1px solid var(--border-1);
|
||
border-radius:6px;overflow:hidden;
|
||
}
|
||
.ota-progress-bar{height:100%;width:0;background:var(--accent);transition:width .15s}
|
||
.ota-feedback{font-size:12px;font-family:var(--font-terminal);min-height:18px;color:var(--ink-3)}
|
||
.btn-ota{
|
||
background:var(--accent);color:#1a1a1a;font-weight:700;
|
||
border:none;border-radius:9px;padding:9px 14px;cursor:pointer;
|
||
font-size:12px;font-family:var(--font-ui);white-space:nowrap;
|
||
}
|
||
.btn-ota:hover{background:var(--accent-soft);color:#fff}
|
||
.btn-ota:disabled{background:var(--bg-4);color:var(--ink-4);cursor:not-allowed}
|
||
@media(max-width:700px){
|
||
.settings-grid{grid-template-columns:1fr}
|
||
.ota-row{grid-template-columns:1fr}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ══ HEADER ══════════════════════════════════════════════════════════ -->
|
||
<header>
|
||
<div class="logo">ESP<span> Jardin</span></div>
|
||
<div id="wsLed" class="ws-led" title="WebSocket"></div>
|
||
<div id="rssiBadge" class="rssi-badge">RSSI —</div>
|
||
<div class="header-gap"></div>
|
||
<button class="btn-icon" id="btnRestart" title="Redémarrer">⟳</button>
|
||
<button class="btn-icon" id="btnSettings" title="Paramètres">⚙</button>
|
||
<button class="btn-icon btn-cfg" id="btnCfgMobile" title="Configuration">⚙</button>
|
||
<button class="btn-icon" id="btnWifi" title="Gestion WiFi">📶</button>
|
||
<button class="btn-icon" id="btnTheme" title="Basculer thème">☾</button>
|
||
</header>
|
||
|
||
<!-- ══ CORPS PRINCIPAL ══════════════════════════════════════════════════ -->
|
||
<div class="main-wrap">
|
||
|
||
<!-- ─ Contenu ─────────────────────────────────────────── -->
|
||
<main>
|
||
|
||
<!-- Grille 3 sondes -->
|
||
<div class="sondes-grid" id="sondesGrid">
|
||
<!-- Sonde 0 -->
|
||
<div class="sonde-card" id="sonde0">
|
||
<div class="sonde-nom" id="nom0">SONDE 1</div>
|
||
<div id="affTemp0">
|
||
<div class="sonde-temp">—<span class="unite">°C</span></div>
|
||
</div>
|
||
<div class="sonde-status" id="status0">En attente…</div>
|
||
</div>
|
||
<!-- Sonde 1 -->
|
||
<div class="sonde-card" id="sonde1">
|
||
<div class="sonde-nom" id="nom1">SONDE 2</div>
|
||
<div id="affTemp1">
|
||
<div class="sonde-temp">—<span class="unite">°C</span></div>
|
||
</div>
|
||
<div class="sonde-status" id="status1">En attente…</div>
|
||
</div>
|
||
<!-- Sonde 2 -->
|
||
<div class="sonde-card" id="sonde2">
|
||
<div class="sonde-nom" id="nom2">SONDE 3</div>
|
||
<div id="affTemp2">
|
||
<div class="sonde-temp">—<span class="unite">°C</span></div>
|
||
</div>
|
||
<div class="sonde-status" id="status2">En attente…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Graphique 24h -->
|
||
<div class="chart-card">
|
||
<div class="card-titre">Historique 24h — températures</div>
|
||
<div class="chart-wrap">
|
||
<canvas id="chartTemp"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<!-- ─ Panneau droit ────────────────────────────────────── -->
|
||
<aside class="panel-right">
|
||
|
||
<!-- Statut système -->
|
||
<div class="stat-tile">
|
||
<div class="tile-titre">Statut système</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">WiFi</span>
|
||
<span id="stWifi" class="badge">—</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">Mode</span>
|
||
<span id="stMode" class="stat-val">—</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">RSSI</span>
|
||
<span id="stRssi" class="stat-val">—</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">MQTT</span>
|
||
<span id="stMqtt" class="badge">—</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">RAM libre</span>
|
||
<span id="stRam" class="stat-val">—</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">Uptime</span>
|
||
<span id="stUptime" class="stat-val">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Formulaire config -->
|
||
<div class="form-tile">
|
||
<div class="tile-titre">Configuration</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="cfgInterval">Intervalle (ms)</label>
|
||
<input class="fi" id="cfgInterval" type="number" value="10000" min="1000" step="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="cfgBroker">Broker MQTT</label>
|
||
<input class="fi" id="cfgBroker" type="text" value="10.0.0.3" placeholder="IP du broker">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="cfgPort">Port MQTT</label>
|
||
<input class="fi" id="cfgPort" type="number" value="1883" min="1" max="65535">
|
||
</div>
|
||
<button class="btn-apply" id="btnApply">Appliquer</button>
|
||
<div class="config-feedback" id="cfgFeedback"></div>
|
||
</div>
|
||
|
||
</aside>
|
||
</div>
|
||
|
||
<!-- ══ STATUS BAR ════════════════════════════════════════════════════ -->
|
||
<div class="statusbar">
|
||
<div class="sb-seg">
|
||
<div id="sbWsLed" class="sb-dot" style="background:var(--ink-4)"></div>
|
||
<span id="sbWsLabel">WS déconnecté</span>
|
||
</div>
|
||
<div class="sb-seg">
|
||
<div id="sbModeLed" class="sb-dot" style="background:var(--blue)"></div>
|
||
<span id="sbModeLabel">WiFi —</span>
|
||
</div>
|
||
<div class="sb-seg">
|
||
<div id="sbMqttLed" class="sb-dot" style="background:var(--ink-4)"></div>
|
||
<span id="sbMqttLabel">MQTT —</span>
|
||
</div>
|
||
<div class="sb-seg">
|
||
<span id="sbSondes">Sondes actives : —</span>
|
||
</div>
|
||
<div class="sb-seg">
|
||
<span id="versionBadge">FW — / UI —</span>
|
||
</div>
|
||
<div class="sb-gap"></div>
|
||
<div class="sb-clock" id="sbClock">—</div>
|
||
</div>
|
||
|
||
<!-- ══ MODAL CONFIG (mobile) ══════════════════════════════════════════ -->
|
||
<div class="modal-backdrop" id="modalBackdrop">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-titre">Configuration</span>
|
||
<button class="btn-close" id="btnCloseModal">✕</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="mCfgInterval">Intervalle (ms)</label>
|
||
<input class="fi" id="mCfgInterval" type="number" value="10000" min="1000" step="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="mCfgBroker">Broker MQTT</label>
|
||
<input class="fi" id="mCfgBroker" type="text" value="10.0.0.3" placeholder="IP du broker">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="mCfgPort">Port MQTT</label>
|
||
<input class="fi" id="mCfgPort" type="number" value="1883" min="1" max="65535">
|
||
</div>
|
||
<button class="btn-apply" id="mBtnApply">Appliquer</button>
|
||
<div class="config-feedback" id="mCfgFeedback"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ MODAL PARAMÈTRES ══════════════════════════════════════════════ -->
|
||
<div class="settings-modal-backdrop" id="settingsModalBackdrop">
|
||
<div class="settings-modal">
|
||
<div class="settings-header">
|
||
<span class="settings-title">⚙ Paramètres</span>
|
||
<button class="btn-close" id="btnCloseSettings">✕</button>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Configuration</div>
|
||
<div class="settings-grid">
|
||
<div class="form-group">
|
||
<label class="fl" for="sCfgInterval">Intervalle (ms)</label>
|
||
<input class="fi" id="sCfgInterval" type="number" value="10000" min="1000" step="1000">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="sCfgBroker">Broker MQTT</label>
|
||
<input class="fi" id="sCfgBroker" type="text" value="10.0.0.3" placeholder="IP du broker">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="fl" for="sCfgPort">Port MQTT</label>
|
||
<input class="fi" id="sCfgPort" type="number" value="1883" min="1" max="65535">
|
||
</div>
|
||
</div>
|
||
<button class="btn-apply" id="sBtnApply">Appliquer</button>
|
||
<div class="config-feedback" id="sCfgFeedback"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Mise à jour OTA firmware</div>
|
||
<div class="ota-row">
|
||
<input class="ota-file" id="otaFirmwareFile" type="file" accept=".bin,application/octet-stream">
|
||
<button class="btn-ota" id="btnOtaFirmware">Envoyer firmware</button>
|
||
</div>
|
||
<div class="ota-progress"><div class="ota-progress-bar" id="otaFirmwareBar"></div></div>
|
||
<div class="ota-feedback" id="otaFirmwareFeedback">Fichier attendu : firmware.bin</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Mise à jour OTA filesystem</div>
|
||
<div class="ota-row">
|
||
<input class="ota-file" id="otaFilesystemFile" type="file" accept=".bin,application/octet-stream">
|
||
<button class="btn-ota" id="btnOtaFilesystem">Envoyer LittleFS</button>
|
||
</div>
|
||
<div class="ota-progress"><div class="ota-progress-bar" id="otaFilesystemBar"></div></div>
|
||
<div class="ota-feedback" id="otaFilesystemFeedback">Fichier attendu : littlefs.bin</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ MODAL WIFI ═════════════════════════════════════════════════════ -->
|
||
<div class="wifi-modal-backdrop" id="wifiModalBackdrop">
|
||
<div class="wifi-modal">
|
||
|
||
<!-- En-tête -->
|
||
<div class="wifi-modal-header">
|
||
<span class="wifi-modal-titre">📶 Gestion WiFi</span>
|
||
<button class="btn-close" id="btnCloseWifi">✕</button>
|
||
</div>
|
||
|
||
<!-- Réseau actuel -->
|
||
<div class="wifi-current">
|
||
<div class="wifi-current-titre">Réseau actuel</div>
|
||
<div class="wifi-current-row">
|
||
<span class="wifi-current-label">SSID</span>
|
||
<span class="wifi-current-val" id="wifiCurSsid">—</span>
|
||
</div>
|
||
<div class="wifi-current-row">
|
||
<span class="wifi-current-label">Adresse IP</span>
|
||
<span class="wifi-current-val" id="wifiCurIp">—</span>
|
||
</div>
|
||
<div class="wifi-current-row">
|
||
<span class="wifi-current-label">RSSI</span>
|
||
<span class="wifi-current-val" id="wifiCurRssi">—</span>
|
||
</div>
|
||
<div class="wifi-current-row">
|
||
<span class="wifi-current-label">Mode</span>
|
||
<span class="wifi-current-val" id="wifiCurMode">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Scanner réseaux -->
|
||
<div>
|
||
<div class="wifi-scan-header">
|
||
<span class="wifi-scan-titre">Réseaux disponibles</span>
|
||
<button class="btn-scan" id="btnScan">🔎 Scanner</button>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<div class="wifi-networks-list" id="wifiNetworksList">
|
||
<div class="wifi-scan-msg">Appuyer sur "Scanner" pour détecter les réseaux.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Formulaire connexion -->
|
||
<div>
|
||
<div class="wifi-form-titre" style="margin-bottom:10px">Connexion</div>
|
||
<div class="wifi-form">
|
||
<div class="wifi-field">
|
||
<label class="wifi-label" for="wifiSsidInput">SSID</label>
|
||
<input class="wifi-input" id="wifiSsidInput" type="text" placeholder="Nom du réseau WiFi" autocomplete="off">
|
||
</div>
|
||
<div class="wifi-field">
|
||
<label class="wifi-label" for="wifiPassInput">Mot de passe</label>
|
||
<div class="wifi-pass-wrap">
|
||
<input class="wifi-input" id="wifiPassInput" type="password" placeholder="Mot de passe" autocomplete="new-password">
|
||
<button class="wifi-pass-toggle" id="btnTogglePass" type="button" title="Afficher/masquer">👁</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn-wifi-save" id="btnWifiSave">Sauvegarder et redémarrer</button>
|
||
<div class="wifi-feedback" id="wifiFeedback"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ SCRIPT ═════════════════════════════════════════════════════════ -->
|
||
<script>
|
||
(function(){
|
||
'use strict';
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
CONSTANTES
|
||
────────────────────────────────────────────────────────── */
|
||
var MAX_POINTS = 288;
|
||
var UI_VERSION = '1.2.2';
|
||
var COULEURS = ['#fe8019','#3db0d1','#c882c8'];
|
||
var NOMS_DEFAUT = ['T°C Ext','T°C Serre','T°C Sol'];
|
||
var WS_RETRY_MS = 3000;
|
||
var STATUS_REFRESH_MS = 30000;
|
||
var HIST_REFRESH_MS = 60000;
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
ÉTAT GLOBAL
|
||
────────────────────────────────────────────────────────── */
|
||
var ws = null;
|
||
var wsReady = false;
|
||
var chartInst = null;
|
||
var nomsReels = NOMS_DEFAUT.slice();
|
||
var premierMessage = true;
|
||
var statusTimer = null;
|
||
var dernierHistSeq = 0;
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
UTILITAIRES
|
||
────────────────────────────────────────────────────────── */
|
||
function fmtUptime(sec){
|
||
sec = sec || 0;
|
||
var h = Math.floor(sec/3600);
|
||
var m = Math.floor((sec%3600)/60);
|
||
var s = sec%60;
|
||
return (h>0?h+'h ':'')+m+'m '+s+'s';
|
||
}
|
||
|
||
function fmtHeure(ts){
|
||
var d = new Date(ts*1000);
|
||
return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||
}
|
||
|
||
function fmtPointHistorique(pt){
|
||
var d;
|
||
if(pt.age_s!==undefined){
|
||
d = new Date(Date.now() - (parseInt(pt.age_s)||0)*1000);
|
||
} else {
|
||
d = new Date((parseInt(pt.ts)||0)*1000);
|
||
}
|
||
return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||
}
|
||
|
||
function fmtRam(b){
|
||
b = b || 0;
|
||
if(b>=1024)return (b/1024).toFixed(0)+' ko';
|
||
return b+' o';
|
||
}
|
||
|
||
function cssVar(name){
|
||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
THÈME
|
||
────────────────────────────────────────────────────────── */
|
||
var btnTheme = document.getElementById('btnTheme');
|
||
btnTheme.addEventListener('click',function(){
|
||
var html = document.documentElement;
|
||
var th = html.getAttribute('data-theme')==='dark'?'light':'dark';
|
||
html.setAttribute('data-theme',th);
|
||
btnTheme.textContent = th==='dark'?'☾':'☼';
|
||
if(chartInst) mettreAJourCouleursFond();
|
||
localStorage.setItem('theme',th);
|
||
});
|
||
// Restaurer thème
|
||
(function(){
|
||
var th = localStorage.getItem('theme');
|
||
if(th){
|
||
document.documentElement.setAttribute('data-theme',th);
|
||
btnTheme.textContent = th==='dark'?'☾':'☼';
|
||
}
|
||
})();
|
||
|
||
function mettreAJourCouleursFond(){
|
||
if(!chartInst) return;
|
||
var bg = cssVar('--bg-3');
|
||
var ink3 = cssVar('--ink-3');
|
||
var border1 = cssVar('--border-1');
|
||
chartInst.options.scales.x.grid.color = border1;
|
||
chartInst.options.scales.x.ticks.color = ink3;
|
||
chartInst.options.scales.y.grid.color = border1;
|
||
chartInst.options.scales.y.ticks.color = ink3;
|
||
chartInst.options.plugins.legend.labels.color = ink3;
|
||
chartInst.update('none');
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
CHART.JS
|
||
────────────────────────────────────────────────────────── */
|
||
function initChart(noms){
|
||
if(typeof Chart === 'undefined'){
|
||
var w = document.querySelector('.chart-wrap');
|
||
if(w) w.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--err);font-family:var(--font-terminal);font-size:13px;">Graphique indisponible (Chart.js non chargé)</div>';
|
||
return;
|
||
}
|
||
var ctx = document.getElementById('chartTemp').getContext('2d');
|
||
var datasets = noms.map(function(n,i){
|
||
return {
|
||
label:n,
|
||
data:[],
|
||
borderColor:COULEURS[i],
|
||
backgroundColor:'transparent',
|
||
borderWidth:1.5,
|
||
pointRadius:0,
|
||
spanGaps:false,
|
||
tension:0.3
|
||
};
|
||
});
|
||
chartInst = new Chart(ctx,{
|
||
type:'line',
|
||
data:{labels:[],datasets:datasets},
|
||
options:{
|
||
animation:false,
|
||
responsive:true,
|
||
maintainAspectRatio:false,
|
||
interaction:{mode:'index',intersect:false},
|
||
plugins:{
|
||
legend:{
|
||
display:true,
|
||
position:'top',
|
||
labels:{
|
||
color:cssVar('--ink-3'),
|
||
font:{family:"'JetBrains Mono',monospace",size:11},
|
||
boxWidth:16,padding:12
|
||
}
|
||
},
|
||
tooltip:{
|
||
backgroundColor:cssVar('--bg-3'),
|
||
titleColor:cssVar('--ink-2'),
|
||
bodyColor:cssVar('--ink-1'),
|
||
borderColor:cssVar('--border-2'),
|
||
borderWidth:1,
|
||
callbacks:{
|
||
label:function(ctx){
|
||
var v = ctx.parsed.y;
|
||
return v==null?'—':ctx.dataset.label+': '+v.toFixed(1)+'°C';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales:{
|
||
x:{
|
||
grid:{color:cssVar('--border-1')},
|
||
ticks:{
|
||
color:cssVar('--ink-3'),
|
||
font:{family:"'Share Tech Mono',monospace",size:10},
|
||
maxTicksLimit:12,
|
||
maxRotation:0
|
||
}
|
||
},
|
||
y:{
|
||
grid:{color:cssVar('--border-1')},
|
||
ticks:{
|
||
color:cssVar('--ink-3'),
|
||
font:{family:"'JetBrains Mono',monospace",size:10},
|
||
callback:function(v){return v+'°C'}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function ajouterPointChart(label,valeurs){
|
||
/* valeurs = [v0, v1, v2] — null si erreur */
|
||
if(!chartInst) return;
|
||
var d = chartInst.data;
|
||
d.labels.push(label);
|
||
for(var i=0;i<3;i++){
|
||
d.datasets[i].data.push(valeurs[i]);
|
||
}
|
||
if(d.labels.length>MAX_POINTS){
|
||
d.labels.shift();
|
||
for(var j=0;j<3;j++) d.datasets[j].data.shift();
|
||
}
|
||
chartInst.update('none');
|
||
}
|
||
|
||
function chargerHistorique(){
|
||
fetch('/api/v1/history')
|
||
.then(function(r){return r.json();})
|
||
.then(function(res){
|
||
if(!chartInst) return;
|
||
var hist = Array.isArray(res) ? res : (res.points || []);
|
||
if(!hist||!hist.length)return;
|
||
if(res.sequence!==undefined) dernierHistSeq = res.sequence;
|
||
var d = chartInst.data;
|
||
d.labels=[];
|
||
for(var i=0;i<3;i++) d.datasets[i].data=[];
|
||
hist.forEach(function(pt){
|
||
d.labels.push(fmtPointHistorique(pt));
|
||
for(var i=0;i<3;i++){
|
||
var v = pt.t&&pt.t[i]!=null?parseFloat(pt.t[i]):null;
|
||
d.datasets[i].data.push(isNaN(v)?null:v);
|
||
}
|
||
});
|
||
chartInst.update('none');
|
||
})
|
||
.catch(function(){/* pas d'historique disponible */});
|
||
}
|
||
|
||
function chargerDernieresValeurs(){
|
||
fetch('/api/v1/readings/latest')
|
||
.then(function(r){return r.json();})
|
||
.then(function(res){
|
||
var readings = res.readings || [];
|
||
var actives = 0;
|
||
readings.forEach(function(s){
|
||
var i = parseInt(s.index);
|
||
if(isNaN(i) || i<0 || i>=3) return;
|
||
majSonde(i, s.name||nomsReels[i], s.temperature, s.error);
|
||
if(!s.error) actives++;
|
||
});
|
||
document.getElementById('sbSondes').textContent='Sondes actives : '+actives+'/3';
|
||
})
|
||
.catch(function(){});
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
CARTES SONDES
|
||
────────────────────────────────────────────────────────── */
|
||
function majSonde(i,nom,temp,erreur){
|
||
var card = document.getElementById('sonde'+i);
|
||
var nomEl = document.getElementById('nom'+i);
|
||
var affEl = document.getElementById('affTemp'+i);
|
||
var stEl = document.getElementById('status'+i);
|
||
nomEl.textContent = nom.toUpperCase();
|
||
if(erreur){
|
||
card.classList.add('erreur');
|
||
affEl.innerHTML = '<div class="sonde-erreur-txt">Sonde déconnectée</div>';
|
||
stEl.textContent = 'Erreur de lecture';
|
||
stEl.style.color = 'var(--err)';
|
||
} else {
|
||
card.classList.remove('erreur');
|
||
var v = parseFloat(temp).toFixed(1);
|
||
affEl.innerHTML = '<div class="sonde-temp">'+v+'<span class="unite">°C</span></div>';
|
||
stEl.textContent = 'Mis à jour à '+heureMaintenant();
|
||
stEl.style.color = 'var(--ink-4)';
|
||
}
|
||
}
|
||
|
||
function heureMaintenant(){
|
||
var d = new Date();
|
||
return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0');
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
STATUT SYSTÈME
|
||
────────────────────────────────────────────────────────── */
|
||
function chargerStatus(){
|
||
fetch('/api/status')
|
||
.then(function(r){return r.json();})
|
||
.then(function(s){
|
||
majStatus(s);
|
||
})
|
||
.catch(function(){});
|
||
}
|
||
|
||
function majStatus(s){
|
||
var versionBadge = document.getElementById('versionBadge');
|
||
if(versionBadge) versionBadge.textContent = 'FW ' + (s.version || '—') + ' / UI ' + UI_VERSION;
|
||
if(s.mqttBroker){
|
||
['cfgBroker','mCfgBroker','sCfgBroker'].forEach(function(id){
|
||
var el = document.getElementById(id);
|
||
if(el && !el.matches(':focus')) el.value = s.mqttBroker;
|
||
});
|
||
}
|
||
if(s.mqttPort){
|
||
['cfgPort','mCfgPort','sCfgPort'].forEach(function(id){
|
||
var el = document.getElementById(id);
|
||
if(el && !el.matches(':focus')) el.value = s.mqttPort;
|
||
});
|
||
}
|
||
// Panneau droit
|
||
setWifi(s.rssi,s.modeAP);
|
||
setMqtt(s.mqttConnecte);
|
||
var stRssi = document.getElementById('stRssi');
|
||
if(stRssi) stRssi.textContent = s.rssi+' dBm';
|
||
var stRam = document.getElementById('stRam');
|
||
if(stRam) stRam.textContent = fmtRam(s.ramLibre);
|
||
var stUp = document.getElementById('stUptime');
|
||
if(stUp) stUp.textContent = fmtUptime(s.uptime);
|
||
// Header RSSI
|
||
document.getElementById('rssiBadge').textContent = s.rssi+' dBm';
|
||
// Status bar mode
|
||
var modeTxt = s.modeAP?'AP: ESP_CHEF_JARDIN':'STA';
|
||
document.getElementById('sbModeLabel').textContent = 'WiFi '+modeTxt;
|
||
document.getElementById('sbModeLed').style.background = s.modeAP?cssVar('--warn'):cssVar('--blue');
|
||
// Status bar MQTT
|
||
document.getElementById('sbMqttLabel').textContent = 'MQTT '+(s.mqttConnecte?'OK':'—');
|
||
document.getElementById('sbMqttLed').style.background = s.mqttConnecte?cssVar('--ok'):cssVar('--ink-4');
|
||
}
|
||
|
||
function setWifi(rssi,modeAP){
|
||
var el = document.getElementById('stWifi');
|
||
var modeEl = document.getElementById('stMode');
|
||
if(el){
|
||
el.className='badge ok';
|
||
el.textContent='Connecté';
|
||
}
|
||
if(modeEl) modeEl.textContent = modeAP?'AP (Hotspot)':'Station (STA)';
|
||
}
|
||
|
||
function setMqtt(ok){
|
||
var el = document.getElementById('stMqtt');
|
||
if(!el) return;
|
||
if(ok){ el.className='badge ok'; el.textContent='Connecté'; }
|
||
else { el.className='badge err'; el.textContent='Hors ligne'; }
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
WEBSOCKET
|
||
────────────────────────────────────────────────────────── */
|
||
function setLedWs(etat){
|
||
/* etat: 'ok'|'err'|'warn' */
|
||
var led = document.getElementById('wsLed');
|
||
var sbLed = document.getElementById('sbWsLed');
|
||
var sbLabel = document.getElementById('sbWsLabel');
|
||
led.className='ws-led '+etat;
|
||
sbLed.style.background = etat==='ok'?cssVar('--ok'):etat==='warn'?cssVar('--warn'):cssVar('--err');
|
||
sbLabel.textContent = etat==='ok'?'WS connecté':etat==='warn'?'WS reconnexion…':'WS déconnecté';
|
||
}
|
||
|
||
function connecterWS(){
|
||
if(ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING){
|
||
ws.onclose = null;
|
||
ws.close();
|
||
}
|
||
var proto = location.protocol==='https:'?'wss':'ws';
|
||
var url = proto+'://'+location.host+'/ws';
|
||
setLedWs('warn');
|
||
try{ ws = new WebSocket(url); }
|
||
catch(e){ setLedWs('err'); return; }
|
||
|
||
ws.onopen = function(){
|
||
wsReady=true;
|
||
setLedWs('ok');
|
||
};
|
||
|
||
ws.onmessage = function(evt){
|
||
var data;
|
||
try{ data=JSON.parse(evt.data); }catch(e){ return; }
|
||
traiterMessageWS(data);
|
||
};
|
||
|
||
ws.onclose = function(){
|
||
wsReady=false;
|
||
setLedWs('err');
|
||
setTimeout(connecterWS, WS_RETRY_MS);
|
||
};
|
||
|
||
ws.onerror = function(){
|
||
setLedWs('err');
|
||
};
|
||
}
|
||
|
||
function traiterMessageWS(data){
|
||
if(!data||!data.sondes) return;
|
||
|
||
// Mise à jour des vrais noms au premier message
|
||
if(premierMessage){
|
||
premierMessage=false;
|
||
if(chartInst){
|
||
data.sondes.forEach(function(s,i){
|
||
if(s.nom){ nomsReels[i]=s.nom; chartInst.data.datasets[i].label=s.nom; }
|
||
});
|
||
chartInst.update('none');
|
||
} else {
|
||
data.sondes.forEach(function(s,i){
|
||
if(s.nom) nomsReels[i]=s.nom;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sondes
|
||
var actives=0;
|
||
data.sondes.forEach(function(s,i){
|
||
majSonde(i, s.nom||nomsReels[i], s.temp, s.erreur);
|
||
if(!s.erreur) actives++;
|
||
});
|
||
document.getElementById('sbSondes').textContent='Sondes actives : '+actives+'/3';
|
||
|
||
// RSSI du message WS si présent
|
||
if(data.rssi!==undefined){
|
||
document.getElementById('rssiBadge').textContent=data.rssi+' dBm';
|
||
}
|
||
|
||
// Le graphique utilise uniquement les moyennes 5 min.
|
||
if(data.histSeq!==undefined && data.histSeq!==dernierHistSeq){
|
||
chargerHistorique();
|
||
}
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
CONFIGURATION
|
||
────────────────────────────────────────────────────────── */
|
||
function envoyerConfig(intervalEl,brokerEl,portEl,feedbackEl){
|
||
var payload={
|
||
intervalleMs:parseInt(intervalEl.value)||10000,
|
||
mqttBroker:brokerEl.value.trim(),
|
||
mqttPort:parseInt(portEl.value)||1883
|
||
};
|
||
fetch('/api/config',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify(payload)
|
||
})
|
||
.then(function(r){
|
||
feedbackEl.style.color='var(--ok)';
|
||
feedbackEl.textContent=r.ok?'Configuration appliquée.':'Erreur (code '+r.status+')';
|
||
if(!r.ok) feedbackEl.style.color='var(--err)';
|
||
})
|
||
.catch(function(){
|
||
feedbackEl.style.color='var(--err)';
|
||
feedbackEl.textContent='Erreur réseau.';
|
||
});
|
||
// Synchroniser les deux formulaires
|
||
document.getElementById('cfgInterval').value=payload.intervalleMs;
|
||
document.getElementById('cfgBroker').value=payload.mqttBroker;
|
||
document.getElementById('cfgPort').value=payload.mqttPort;
|
||
document.getElementById('mCfgInterval').value=payload.intervalleMs;
|
||
document.getElementById('mCfgBroker').value=payload.mqttBroker;
|
||
document.getElementById('mCfgPort').value=payload.mqttPort;
|
||
document.getElementById('sCfgInterval').value=payload.intervalleMs;
|
||
document.getElementById('sCfgBroker').value=payload.mqttBroker;
|
||
document.getElementById('sCfgPort').value=payload.mqttPort;
|
||
}
|
||
|
||
document.getElementById('btnApply').addEventListener('click',function(){
|
||
envoyerConfig(
|
||
document.getElementById('cfgInterval'),
|
||
document.getElementById('cfgBroker'),
|
||
document.getElementById('cfgPort'),
|
||
document.getElementById('cfgFeedback')
|
||
);
|
||
});
|
||
|
||
document.getElementById('mBtnApply').addEventListener('click',function(){
|
||
envoyerConfig(
|
||
document.getElementById('mCfgInterval'),
|
||
document.getElementById('mCfgBroker'),
|
||
document.getElementById('mCfgPort'),
|
||
document.getElementById('mCfgFeedback')
|
||
);
|
||
});
|
||
|
||
function redemarrerEsp(){
|
||
if(!confirm('Redémarrer esp_jardin maintenant ?')) return;
|
||
fetch('/api/restart',{method:'POST'})
|
||
.catch(function(){});
|
||
}
|
||
|
||
document.getElementById('btnRestart').addEventListener('click', redemarrerEsp);
|
||
|
||
document.getElementById('sBtnApply').addEventListener('click',function(){
|
||
envoyerConfig(
|
||
document.getElementById('sCfgInterval'),
|
||
document.getElementById('sCfgBroker'),
|
||
document.getElementById('sCfgPort'),
|
||
document.getElementById('sCfgFeedback')
|
||
);
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
MODAL MOBILE
|
||
────────────────────────────────────────────────────────── */
|
||
var modal = document.getElementById('modalBackdrop');
|
||
document.getElementById('btnCfgMobile').addEventListener('click',function(){
|
||
modal.classList.add('open');
|
||
});
|
||
document.getElementById('btnCloseModal').addEventListener('click',function(){
|
||
modal.classList.remove('open');
|
||
});
|
||
modal.addEventListener('click',function(e){
|
||
if(e.target===modal) modal.classList.remove('open');
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
PARAMÈTRES ET OTA HTTP
|
||
────────────────────────────────────────────────────────── */
|
||
var settingsModal = document.getElementById('settingsModalBackdrop');
|
||
|
||
function ouvrirSettings(){
|
||
document.getElementById('sCfgInterval').value = document.getElementById('cfgInterval').value;
|
||
document.getElementById('sCfgBroker').value = document.getElementById('cfgBroker').value;
|
||
document.getElementById('sCfgPort').value = document.getElementById('cfgPort').value;
|
||
settingsModal.classList.add('open');
|
||
}
|
||
|
||
function fermerSettings(){
|
||
settingsModal.classList.remove('open');
|
||
}
|
||
|
||
function envoyerOta(type){
|
||
var isFirmware = type === 'firmware';
|
||
var fileEl = document.getElementById(isFirmware ? 'otaFirmwareFile' : 'otaFilesystemFile');
|
||
var bar = document.getElementById(isFirmware ? 'otaFirmwareBar' : 'otaFilesystemBar');
|
||
var feedback = document.getElementById(isFirmware ? 'otaFirmwareFeedback' : 'otaFilesystemFeedback');
|
||
var btn = document.getElementById(isFirmware ? 'btnOtaFirmware' : 'btnOtaFilesystem');
|
||
var file = fileEl.files && fileEl.files[0];
|
||
|
||
if(!file){
|
||
feedback.style.color = 'var(--err)';
|
||
feedback.textContent = 'Sélectionner un fichier .bin.';
|
||
return;
|
||
}
|
||
|
||
if(!/\.bin$/i.test(file.name)){
|
||
feedback.style.color = 'var(--err)';
|
||
feedback.textContent = 'Le fichier doit être un .bin.';
|
||
return;
|
||
}
|
||
|
||
var form = new FormData();
|
||
form.append('update', file, file.name);
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', isFirmware ? '/api/ota/firmware' : '/api/ota/filesystem');
|
||
|
||
btn.disabled = true;
|
||
bar.style.width = '0%';
|
||
feedback.style.color = 'var(--warn)';
|
||
feedback.textContent = 'Upload en cours…';
|
||
|
||
xhr.upload.onprogress = function(evt){
|
||
if(evt.lengthComputable){
|
||
var pct = Math.round((evt.loaded / evt.total) * 100);
|
||
bar.style.width = pct + '%';
|
||
feedback.textContent = 'Upload en cours… ' + pct + '%';
|
||
}
|
||
};
|
||
|
||
xhr.onload = function(){
|
||
if(xhr.status >= 200 && xhr.status < 300){
|
||
bar.style.width = '100%';
|
||
feedback.style.color = 'var(--ok)';
|
||
feedback.textContent = 'Mise à jour envoyée. Redémarrage de l’ESP…';
|
||
} else {
|
||
var msg = 'Erreur OTA HTTP ' + xhr.status + '.';
|
||
try{
|
||
var res = JSON.parse(xhr.responseText || '{}');
|
||
if(res.erreur) msg = 'Erreur OTA : ' + res.erreur;
|
||
}catch(e){}
|
||
feedback.style.color = 'var(--err)';
|
||
feedback.textContent = msg;
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
|
||
xhr.onerror = function(){
|
||
feedback.style.color = 'var(--err)';
|
||
feedback.textContent = 'Connexion interrompue pendant l’upload.';
|
||
btn.disabled = false;
|
||
};
|
||
|
||
xhr.send(form);
|
||
}
|
||
|
||
document.getElementById('btnSettings').addEventListener('click', ouvrirSettings);
|
||
document.getElementById('btnCloseSettings').addEventListener('click', fermerSettings);
|
||
settingsModal.addEventListener('click',function(e){
|
||
if(e.target===settingsModal) fermerSettings();
|
||
});
|
||
document.getElementById('btnOtaFirmware').addEventListener('click',function(){
|
||
envoyerOta('firmware');
|
||
});
|
||
document.getElementById('btnOtaFilesystem').addEventListener('click',function(){
|
||
envoyerOta('filesystem');
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
HORLOGE STATUS BAR
|
||
────────────────────────────────────────────────────────── */
|
||
function majHorloge(){
|
||
var d = new Date();
|
||
var h = String(d.getHours()).padStart(2,'0');
|
||
var m = String(d.getMinutes()).padStart(2,'0');
|
||
var s = String(d.getSeconds()).padStart(2,'0');
|
||
document.getElementById('sbClock').textContent='esp_jardin | '+h+':'+m+':'+s;
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
GESTION WIFI
|
||
────────────────────────────────────────────────────────── */
|
||
var wifiScanInterval = null;
|
||
|
||
// Convertit un RSSI en barres Unicode ▁▂▃▄▅
|
||
function rssiBarres(rssi) {
|
||
if (rssi >= -55) return '▅▅▅▅▅';
|
||
if (rssi >= -65) return '▄▄▄▄░';
|
||
if (rssi >= -75) return '▃▃▃░░';
|
||
if (rssi >= -85) return '▂▂░░░';
|
||
return '▁░░░░';
|
||
}
|
||
|
||
function ouvrirModalWifi() {
|
||
document.getElementById('wifiModalBackdrop').classList.add('open');
|
||
chargerWifiCurrent();
|
||
}
|
||
|
||
function fermerModalWifi() {
|
||
document.getElementById('wifiModalBackdrop').classList.remove('open');
|
||
stopScanPolling();
|
||
}
|
||
|
||
function chargerWifiCurrent() {
|
||
fetch('/api/wifi/current')
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d) {
|
||
document.getElementById('wifiCurSsid').textContent = d.ssid || '—';
|
||
document.getElementById('wifiCurIp').textContent = d.ip || '—';
|
||
document.getElementById('wifiCurRssi').textContent = d.rssi ? d.rssi + ' dBm' : '—';
|
||
document.getElementById('wifiCurMode').textContent = d.modeAP ? 'Point d\'accès (AP)' : 'Station (STA)';
|
||
})
|
||
.catch(function() {
|
||
document.getElementById('wifiCurSsid').textContent = 'Erreur réseau';
|
||
});
|
||
}
|
||
|
||
function stopScanPolling() {
|
||
if (wifiScanInterval) {
|
||
clearInterval(wifiScanInterval);
|
||
wifiScanInterval = null;
|
||
}
|
||
}
|
||
|
||
function demarrerScan() {
|
||
var btn = document.getElementById('btnScan');
|
||
var liste = document.getElementById('wifiNetworksList');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Scan…';
|
||
liste.innerHTML = '<div class="wifi-scan-msg">Scan en cours…</div>';
|
||
stopScanPolling();
|
||
|
||
// Premier appel immédiat
|
||
fetch('/api/wifi/networks')
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d) { traiterResultatScan(d); })
|
||
.catch(function() {
|
||
liste.innerHTML = '<div class="wifi-scan-msg" style="color:var(--err)">Erreur réseau.</div>';
|
||
btn.disabled = false;
|
||
btn.textContent = '⟳ Réessayer';
|
||
});
|
||
|
||
// Polling toutes les 2s
|
||
wifiScanInterval = setInterval(function() {
|
||
fetch('/api/wifi/networks')
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d) { traiterResultatScan(d); })
|
||
.catch(function() { stopScanPolling(); });
|
||
}, 2000);
|
||
}
|
||
|
||
function traiterResultatScan(d) {
|
||
var btn = document.getElementById('btnScan');
|
||
var liste = document.getElementById('wifiNetworksList');
|
||
|
||
if (d.etat === 'scan_en_cours') {
|
||
// Continuer le polling
|
||
return;
|
||
}
|
||
|
||
// Scan terminé
|
||
stopScanPolling();
|
||
btn.disabled = false;
|
||
btn.textContent = '↻ Rescanner';
|
||
|
||
if (!d.reseaux || d.reseaux.length === 0) {
|
||
liste.innerHTML = '<div class="wifi-scan-msg">Aucun réseau détecté.</div>';
|
||
return;
|
||
}
|
||
|
||
// Tri par RSSI décroissant
|
||
d.reseaux.sort(function(a, b) { return b.rssi - a.rssi; });
|
||
|
||
liste.innerHTML = '';
|
||
d.reseaux.forEach(function(r) {
|
||
var item = document.createElement('div');
|
||
item.className = 'wifi-net-item';
|
||
item.innerHTML =
|
||
'<span class="wifi-net-ssid">' + escHtml(r.ssid) + '</span>' +
|
||
'<span class="wifi-net-signal" title="' + r.rssi + ' dBm">' + rssiBarres(r.rssi) + '</span>' +
|
||
'<span class="wifi-net-lock" title="' + (r.secure ? 'Sécurisé' : 'Ouvert') + '">' +
|
||
(r.secure ? '🔒' : '🔓') +
|
||
'</span>';
|
||
item.addEventListener('click', function() {
|
||
document.getElementById('wifiSsidInput').value = r.ssid;
|
||
document.getElementById('wifiPassInput').value = '';
|
||
document.getElementById('wifiPassInput').focus();
|
||
});
|
||
liste.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g,'&')
|
||
.replace(/</g,'<')
|
||
.replace(/>/g,'>')
|
||
.replace(/"/g,'"');
|
||
}
|
||
|
||
function envoyerWifiConnect() {
|
||
var ssid = document.getElementById('wifiSsidInput').value.trim();
|
||
var pass = document.getElementById('wifiPassInput').value;
|
||
var feedback = document.getElementById('wifiFeedback');
|
||
var btnSave = document.getElementById('btnWifiSave');
|
||
|
||
if (!ssid) {
|
||
feedback.style.color = 'var(--err)';
|
||
feedback.textContent = 'Le SSID ne peut pas être vide.';
|
||
return;
|
||
}
|
||
|
||
btnSave.disabled = true;
|
||
feedback.style.color = 'var(--warn)';
|
||
feedback.textContent = 'Envoi en cours…';
|
||
|
||
fetch('/api/wifi/connect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ssid: ssid, password: pass })
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.ok) {
|
||
feedback.style.color = 'var(--ok)';
|
||
feedback.innerHTML =
|
||
'Redémarrage en cours… (~5s)<br>' +
|
||
'<small style="color:var(--ink-3)">Reconnectez-vous sur <a href="http://esp_jardin.local" style="color:var(--blue)">esp_jardin.local</a></small>';
|
||
} else {
|
||
feedback.style.color = 'var(--err)';
|
||
feedback.textContent = d.erreur || 'Erreur inconnue.';
|
||
btnSave.disabled = false;
|
||
}
|
||
})
|
||
.catch(function() {
|
||
// L'ESP redémarre et coupe la connexion — c'est normal
|
||
feedback.style.color = 'var(--ok)';
|
||
feedback.innerHTML =
|
||
'Redémarrage en cours… (~5s)<br>' +
|
||
'<small style="color:var(--ink-3)">Reconnectez-vous sur <a href="http://esp_jardin.local" style="color:var(--blue)">esp_jardin.local</a></small>';
|
||
});
|
||
}
|
||
|
||
// Événements modal WiFi
|
||
document.getElementById('btnWifi').addEventListener('click', ouvrirModalWifi);
|
||
document.getElementById('btnCloseWifi').addEventListener('click', fermerModalWifi);
|
||
document.getElementById('wifiModalBackdrop').addEventListener('click', function(e) {
|
||
if (e.target === this) fermerModalWifi();
|
||
});
|
||
document.getElementById('btnScan').addEventListener('click', demarrerScan);
|
||
document.getElementById('btnWifiSave').addEventListener('click', envoyerWifiConnect);
|
||
|
||
// Bouton afficher/masquer mot de passe
|
||
document.getElementById('btnTogglePass').addEventListener('click', function() {
|
||
var inp = document.getElementById('wifiPassInput');
|
||
if (inp.type === 'password') {
|
||
inp.type = 'text';
|
||
this.textContent = '🙈';
|
||
} else {
|
||
inp.type = 'password';
|
||
this.textContent = '👁';
|
||
}
|
||
});
|
||
|
||
/* ──────────────────────────────────────────────────────────
|
||
INITIALISATION
|
||
────────────────────────────────────────────────────────── */
|
||
initChart(NOMS_DEFAUT);
|
||
chargerHistorique();
|
||
chargerDernieresValeurs();
|
||
chargerStatus();
|
||
connecterWS();
|
||
setInterval(chargerStatus, STATUS_REFRESH_MS);
|
||
setInterval(chargerHistorique, HIST_REFRESH_MS);
|
||
setInterval(majHorloge, 1000);
|
||
majHorloge();
|
||
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|