f75d35ae5b
- C1: guard typeof Chart === 'undefined' dans initChart(), handler onerror
sur le tag <script> CDN, message fallback "Graphique indisponible"
- C1: guards if(!chartInst) return dans ajouterPointChart(), chargerHistorique(),
traiterMessageWS(), mettreAJourCouleursFond()
- I1: nettoyage ws.close() en début de connecterWS() avant toute reconnexion
- I3: guard if(!chartInst) au début du .then() de chargerHistorique()
- I4: tooltip backgroundColor remplacé par cssVar('--bg-3') (plus hardcodé)
- M2: sec = sec || 0 dans fmtUptime(), b = b || 0 dans fmtRam()
- M3: emoji ☀️ remplacé par ☼ (U+263C, caractère texte pur)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
849 lines
32 KiB
HTML
849 lines
32 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}
|
||
.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">⚙</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-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>
|
||
|
||
<!-- ══ 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){
|
||
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 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/history')
|
||
.then(function(r){return r.json();})
|
||
.then(function(hist){
|
||
if(!chartInst) return;
|
||
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(){
|
||
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';
|
||
}
|
||
|
||
// 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>
|