Files
2026-05-24 13:05:18 +02:00

1453 lines
55 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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=&quot;display:flex;align-items:center;justify-content:center;height:100%;color:var(--err);font-family:var(--font-terminal);font-size:13px;&quot;>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">&#10227;</button>
<button class="btn-icon" id="btnSettings" title="Paramètres">&#9881;</button>
<button class="btn-icon btn-cfg" id="btnCfgMobile" title="Configuration">&#9881;</button>
<button class="btn-icon" id="btnWifi" title="Gestion WiFi">&#128246;</button>
<button class="btn-icon" id="btnTheme" title="Basculer thème">&#9790;</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">&#10005;</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">&#9881; Paramètres</span>
<button class="btn-close" id="btnCloseSettings">&#10005;</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">&#128246; Gestion WiFi</span>
<button class="btn-close" id="btnCloseWifi">&#10005;</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">&#128270; 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">&#128065;</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 lESP…';
} 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 lupload.';
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 ? '&#128274;' : '&#128275;') +
'</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,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
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>