feat: interface web temps réel complète (WebSocket, Chart.js, thème Gruvbox Seventies)

Page auto-contenue LittleFS (31 Ko) : 3 cartes sondes temps réel, graphique 24h Chart.js,
panneau statut/config, bascule dark/light, status bar, reconnexion WS automatique, responsive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 16:53:16 +02:00
parent cd8232bafb
commit d9d2db8b5e
+828
View File
@@ -0,0 +1,828 @@
<!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"></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}
.btn-cfg{display:inline-flex!important}
}
@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}
</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 btn-cfg" id="btnCfgMobile" title="Configuration">&#9881;</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-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>
<!-- ══ SCRIPT ═════════════════════════════════════════════════════════ -->
<script>
(function(){
'use strict';
/* ──────────────────────────────────────────────────────────
CONSTANTES
────────────────────────────────────────────────────────── */
var MAX_POINTS = 288;
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;
/* ──────────────────────────────────────────────────────────
ÉTAT GLOBAL
────────────────────────────────────────────────────────── */
var ws = null;
var wsReady = false;
var chartInst = null;
var nomsReels = NOMS_DEFAUT.slice();
var premierMessage = true;
var statusTimer = null;
/* ──────────────────────────────────────────────────────────
UTILITAIRES
────────────────────────────────────────────────────────── */
function fmtUptime(sec){
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 fmtRam(b){
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(){
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){
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:'rgba(42,35,29,.95)',
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 */
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/history')
.then(function(r){return r.json();})
.then(function(hist){
if(!hist||!hist.length)return;
var d = chartInst.data;
d.labels=[];
for(var i=0;i<3;i++) d.datasets[i].data=[];
hist.forEach(function(pt){
d.labels.push(fmtHeure(pt.ts));
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 */});
}
/* ──────────────────────────────────────────────────────────
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){
// 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(){
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;
data.sondes.forEach(function(s,i){
if(s.nom){ nomsReels[i]=s.nom; chartInst.data.datasets[i].label=s.nom; }
});
chartInst.update('none');
}
// 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';
}
// Graphique — timestamp = maintenant si absent du payload
var ts = data.ts?data.ts:Math.floor(Date.now()/1000);
var valeurs = data.sondes.map(function(s){
if(s.erreur||s.temp==null) return null;
var v=parseFloat(s.temp);
return isNaN(v)?null:v;
});
ajouterPointChart(fmtHeure(ts), valeurs);
}
/* ──────────────────────────────────────────────────────────
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('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')
);
});
/* ──────────────────────────────────────────────────────────
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');
});
/* ──────────────────────────────────────────────────────────
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;
}
/* ──────────────────────────────────────────────────────────
INITIALISATION
────────────────────────────────────────────────────────── */
initChart(NOMS_DEFAUT);
chargerHistorique();
chargerStatus();
connecterWS();
setInterval(chargerStatus, STATUS_REFRESH_MS);
setInterval(majHorloge, 1000);
majHorloge();
})();
</script>
</body>
</html>