Finalisation dashboard EcoCharge dynamique
This commit is contained in:
350
static/script.js
350
static/script.js
@@ -1,7 +1,17 @@
|
||||
let isOnline = true;
|
||||
let lastHeartbeat = Date.now();
|
||||
const TIMEOUT_MS = 5000;
|
||||
// =========================================================
|
||||
// EcoCharge — Dashboard dynamique
|
||||
// - Interroge /api/latest toutes les 4 s (mise à jour temps réel)
|
||||
// - Statut EN LIGNE / HORS LIGNE basé sur l'ancienneté des données
|
||||
// - Graphiques lisibles à double axe Y
|
||||
// =========================================================
|
||||
|
||||
// L'ESP32 est considéré HORS LIGNE si la dernière mesure date de plus de 60 s.
|
||||
const STALE_SECONDS = 60;
|
||||
const POLL_LATEST_MS = 4000;
|
||||
const POLL_HISTORY_MS = 8000;
|
||||
const HISTORY_LIMIT = 15;
|
||||
|
||||
// --- Éléments du DOM ---
|
||||
const statusDot = document.getElementById('system-status');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
@@ -12,12 +22,13 @@ const apiTempEl = document.getElementById('api-temp');
|
||||
const apiDescEl = document.getElementById('api-desc');
|
||||
const apiIconEl = document.getElementById('api-icon');
|
||||
|
||||
const batValEl = document.getElementById('bat-val');
|
||||
const batCircleEl = document.getElementById('bat-circle');
|
||||
const batAlertBadge = document.getElementById('bat-alert-badge');
|
||||
const batAlertIcon = document.getElementById('bat-alert-icon');
|
||||
const batAlertText = document.getElementById('bat-alert');
|
||||
|
||||
const dotEsp = document.getElementById('dot-esp');
|
||||
const badgeEsp = document.getElementById('badge-esp');
|
||||
|
||||
const MAX_POWER = 100;
|
||||
const gaugeCircle = document.querySelector('.gauge-fill circle');
|
||||
const halfCircumference = 283 / 2;
|
||||
@@ -25,6 +36,9 @@ const halfCircumference = 283 / 2;
|
||||
let envChart = null;
|
||||
let energyChart = null;
|
||||
|
||||
// =========================================================
|
||||
// Météo externe (API web open-meteo) — purement informatif
|
||||
// =========================================================
|
||||
function getWeatherDetails(code) {
|
||||
if (code === 0) return { desc: "Dégagé", icon: "bi-brightness-high", color: "text-warning" };
|
||||
if (code >= 1 && code <= 3) return { desc: "Nuageux", icon: "bi-cloud", color: "text-light" };
|
||||
@@ -56,56 +70,60 @@ async function fetchRealWeather() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateBattery(percent, alertTextFromApi = null) {
|
||||
batValEl.innerText = percent + "%";
|
||||
let color = "#34d399";
|
||||
let shadowColor = "rgba(52, 211, 153, 0.4)";
|
||||
// =========================================================
|
||||
// Statut EN LIGNE / HORS LIGNE (cohérent partout)
|
||||
// =========================================================
|
||||
function updateConnectionStatus(online) {
|
||||
if (online) {
|
||||
statusDot.className = "status-dot rounded-circle pulse-online";
|
||||
statusText.innerText = "EN LIGNE";
|
||||
statusText.style.color = "#e2e8f0";
|
||||
|
||||
if (alertTextFromApi && alertTextFromApi !== "none") {
|
||||
color = "#ef4444";
|
||||
shadowColor = "rgba(239, 68, 68, 0.6)";
|
||||
batAlertText.innerText = alertTextFromApi;
|
||||
batAlertIcon.className = "bi bi-exclamation-triangle-fill text-danger";
|
||||
batAlertBadge.className = "badge bg-dark border border-danger text-secondary w-100 py-2";
|
||||
} else if (percent <= 20) {
|
||||
color = "#ef4444";
|
||||
shadowColor = "rgba(239, 68, 68, 0.6)";
|
||||
batAlertText.innerText = "Niveau critique";
|
||||
batAlertIcon.className = "bi bi-exclamation-triangle-fill text-danger";
|
||||
batAlertBadge.className = "badge bg-dark border border-danger text-secondary w-100 py-2";
|
||||
} else if (percent <= 50) {
|
||||
color = "#fbbf24";
|
||||
shadowColor = "rgba(251, 191, 36, 0.5)";
|
||||
batAlertText.innerText = "Niveau moyen";
|
||||
batAlertIcon.className = "bi bi-exclamation-circle text-warning";
|
||||
if (dotEsp) dotEsp.className = "sys-dot bg-success";
|
||||
if (badgeEsp) {
|
||||
badgeEsp.className = "ms-auto sys-badge sys-badge-ok";
|
||||
badgeEsp.innerText = "Connecté";
|
||||
}
|
||||
} else {
|
||||
statusDot.className = "status-dot rounded-circle pulse-offline";
|
||||
statusText.innerText = "HORS LIGNE";
|
||||
statusText.style.color = "#ef4444";
|
||||
|
||||
if (dotEsp) dotEsp.className = "sys-dot bg-danger";
|
||||
if (badgeEsp) {
|
||||
badgeEsp.className = "ms-auto sys-badge sys-badge-err";
|
||||
badgeEsp.innerText = "Hors ligne";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Badge d'état (bas du bloc "État du Système")
|
||||
function updateAlert(alertText) {
|
||||
if (alertText && alertText !== "none") {
|
||||
batAlertText.innerText = alertText;
|
||||
batAlertIcon.className = "bi bi-exclamation-triangle-fill text-warning";
|
||||
batAlertBadge.className = "badge bg-dark border border-warning text-secondary w-100 py-2";
|
||||
} else {
|
||||
batAlertText.innerText = "Système Normal";
|
||||
batAlertIcon.className = "bi bi-check-circle text-success";
|
||||
batAlertBadge.className = "badge bg-dark border border-secondary text-secondary w-100 py-2";
|
||||
}
|
||||
|
||||
const angle = percent * 3.6;
|
||||
batCircleEl.style.background = `conic-gradient(${color} ${angle}deg, #0f172a 0deg)`;
|
||||
batCircleEl.style.boxShadow = `0 0 15px ${shadowColor}, inset 0 0 15px ${shadowColor}`;
|
||||
batValEl.style.textShadow = `0 0 10px ${shadowColor}`;
|
||||
}
|
||||
|
||||
function updateSolar(voltage, current, power, lux) {
|
||||
// =========================================================
|
||||
// Mesures électriques (INA219) — jauge + cartes
|
||||
// =========================================================
|
||||
function updateElectrical(voltage, current, power) {
|
||||
const v = Number(voltage).toFixed(1);
|
||||
const c = Number(current).toFixed(2);
|
||||
const p = Number(power).toFixed(1);
|
||||
const l = Math.floor(Number(lux));
|
||||
|
||||
const efficiency = (p > 0) ? ((p / MAX_POWER) * 100).toFixed(1) : "0.0";
|
||||
|
||||
document.getElementById('powerValue').textContent = p;
|
||||
document.getElementById('voltage').innerHTML = v + ' <span class="stat-unit">V</span>';
|
||||
document.getElementById('current').innerHTML = c + ' <span class="stat-unit">A</span>';
|
||||
document.getElementById('lux').innerHTML = l.toLocaleString() + ' <span class="stat-unit">Lux</span>';
|
||||
document.getElementById('efficiency').innerHTML = efficiency + ' <span class="stat-unit">%</span>';
|
||||
document.getElementById('efficiency').innerHTML = p + ' <span class="stat-unit">W</span>';
|
||||
|
||||
const ratio = Math.min(p / MAX_POWER, 1);
|
||||
const ratio = Math.min(Number(power) / MAX_POWER, 1);
|
||||
const offset = halfCircumference - (ratio * halfCircumference);
|
||||
|
||||
if (gaugeCircle) {
|
||||
@@ -113,6 +131,10 @@ function updateSolar(voltage, current, power, lux) {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Graphique 1 : Historique environnement (DHT11)
|
||||
// Température (axe gauche) + Humidité (axe droit)
|
||||
// =========================================================
|
||||
function createEnvChart() {
|
||||
const ctx = document.getElementById('envChart');
|
||||
if (!ctx) return;
|
||||
@@ -126,60 +148,66 @@ function createEnvChart() {
|
||||
label: 'Température (°C)',
|
||||
data: [],
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.2)',
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.08)',
|
||||
pointBackgroundColor: '#38bdf8',
|
||||
pointBorderColor: '#38bdf8',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
pointRadius: 3,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Humidité (%)',
|
||||
data: [],
|
||||
borderColor: '#ff5c8a',
|
||||
backgroundColor: 'rgba(255, 92, 138, 0.2)',
|
||||
backgroundColor: 'rgba(255, 92, 138, 0.08)',
|
||||
pointBackgroundColor: '#ff5c8a',
|
||||
pointBorderColor: '#ff5c8a',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
pointRadius: 3,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#e2e8f0'
|
||||
}
|
||||
}
|
||||
legend: { labels: { color: '#e2e8f0' } }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)'
|
||||
}
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)'
|
||||
}
|
||||
position: 'left',
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 50,
|
||||
title: { display: false },
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: 100,
|
||||
title: { display: false },
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Graphique 2 : Historique mesures électriques (INA219)
|
||||
// Puissance calculée (axe gauche, W) + Courant mesuré (axe droit, A)
|
||||
// -> deux axes Y = courant lisible même si la puissance varie
|
||||
// -> beginAtZero = échelle stable (la courbe ne "saute" plus)
|
||||
// =========================================================
|
||||
function createEnergyChart() {
|
||||
const ctx = document.getElementById('energyChart');
|
||||
if (!ctx) return;
|
||||
@@ -190,76 +218,94 @@ function createEnergyChart() {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Batterie (%)',
|
||||
data: [],
|
||||
borderColor: '#34d399',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.2)',
|
||||
pointBackgroundColor: '#34d399',
|
||||
pointBorderColor: '#34d399',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: 'Puissance solaire (W)',
|
||||
label: 'Puissance calculée (W)',
|
||||
data: [],
|
||||
borderColor: '#facc15',
|
||||
backgroundColor: 'rgba(250, 204, 21, 0.2)',
|
||||
backgroundColor: 'rgba(250, 204, 21, 0.08)',
|
||||
pointBackgroundColor: '#facc15',
|
||||
pointBorderColor: '#facc15',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
pointRadius: 2,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Courant mesuré (A)',
|
||||
data: [],
|
||||
borderColor: '#34d399',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.08)',
|
||||
pointBackgroundColor: '#34d399',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 3,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#e2e8f0'
|
||||
}
|
||||
}
|
||||
legend: { labels: { color: '#e2e8f0' } }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)'
|
||||
}
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#94a3b8'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.1)'
|
||||
}
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
title: { display: false },
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
title: { display: false },
|
||||
ticks: { color: '#94a3b8' },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHistoryData() {
|
||||
// =========================================================
|
||||
// Récupération de l'historique (graphiques)
|
||||
// =========================================================
|
||||
async function fetchHistory() {
|
||||
try {
|
||||
const response = await fetch('/api/history?limit=10');
|
||||
|
||||
const response = await fetch('/api/history?limit=' + HISTORY_LIMIT);
|
||||
if (!response.ok) {
|
||||
throw new Error("Impossible de récupérer l'historique");
|
||||
}
|
||||
|
||||
const history = await response.json();
|
||||
|
||||
const labels = history.map(item => {
|
||||
const date = new Date(item.created_at);
|
||||
return date.toLocaleTimeString('fr-FR', {
|
||||
// --- Heure de l'axe X : on affiche l'heure LOCALE du navigateur ---
|
||||
// Le timestamp serveur (Docker) peut être en mauvais fuseau (ex. 01:11 au lieu
|
||||
// de 16:08). On ancre donc la mesure la plus récente sur l'heure du navigateur,
|
||||
// et on garde l'écart réel entre les mesures (calculé à partir des timestamps,
|
||||
// ce qui est indépendant du fuseau horaire).
|
||||
const browserNow = Date.now();
|
||||
const times = history.map(item => {
|
||||
const t = new Date(item.created_at).getTime();
|
||||
return Number.isNaN(t) ? null : t;
|
||||
});
|
||||
const lastValid = [...times].reverse().find(t => t !== null);
|
||||
|
||||
const labels = times.map(t => {
|
||||
let d;
|
||||
if (t !== null && lastValid !== undefined) {
|
||||
// décalage réel par rapport à la dernière mesure, reporté sur l'heure du navigateur
|
||||
d = new Date(browserNow - (lastValid - t));
|
||||
} else {
|
||||
d = new Date(browserNow);
|
||||
}
|
||||
return d.toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
@@ -270,86 +316,78 @@ async function fetchHistoryData() {
|
||||
envChart.data.labels = labels;
|
||||
envChart.data.datasets[0].data = history.map(item => item.temperature_ext ?? null);
|
||||
envChart.data.datasets[1].data = history.map(item => item.humidity_ext ?? null);
|
||||
envChart.update();
|
||||
envChart.update('none');
|
||||
}
|
||||
|
||||
if (energyChart) {
|
||||
energyChart.data.labels = labels;
|
||||
energyChart.data.datasets[0].data = history.map(item => item.battery_level ?? null);
|
||||
energyChart.data.datasets[1].data = history.map(item => item.power_pv ?? null);
|
||||
energyChart.update();
|
||||
energyChart.data.datasets[0].data = history.map(item => item.power_pv ?? null);
|
||||
energyChart.data.datasets[1].data = history.map(item => item.current_pv ?? null);
|
||||
energyChart.update('none');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erreur historique :", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDatabaseData() {
|
||||
if (!isOnline) return;
|
||||
|
||||
// =========================================================
|
||||
// Récupération de la dernière mesure (cartes + statut)
|
||||
// =========================================================
|
||||
async function fetchLatest() {
|
||||
try {
|
||||
const response = await fetch('/api/latest');
|
||||
|
||||
// Pas de données en base -> HORS LIGNE
|
||||
if (!response.ok) {
|
||||
throw new Error("Base de données vide ou serveur injoignable");
|
||||
updateConnectionStatus(false);
|
||||
updateAlert(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
tempEl.innerText = data.temperature_ext !== null ? Number(data.temperature_ext).toFixed(1) : "--";
|
||||
humEl.innerText = data.humidity_ext !== null ? Math.floor(data.humidity_ext) : "--";
|
||||
// EN LIGNE seulement si la dernière mesure est récente
|
||||
const age = data.data_age_seconds;
|
||||
const online = (age !== null && age !== undefined && age < STALE_SECONDS);
|
||||
|
||||
if (data.battery_level !== null) {
|
||||
const batteryAlert = data.battery_alert === true || data.battery_alert === "true" || data.battery_alert === 1 || data.battery_alert === "1";
|
||||
updateBattery(Math.floor(data.battery_level), batteryAlert ? "Alerte batterie" : null);
|
||||
}
|
||||
// Mise à jour des valeurs (DHT11 + INA219)
|
||||
tempEl.innerText = (data.temperature_ext !== null && data.temperature_ext !== undefined)
|
||||
? Number(data.temperature_ext).toFixed(1) : "--";
|
||||
humEl.innerText = (data.humidity_ext !== null && data.humidity_ext !== undefined)
|
||||
? Math.round(data.humidity_ext) : "--";
|
||||
|
||||
updateSolar(
|
||||
updateElectrical(
|
||||
data.voltage_pv || 0,
|
||||
data.current_pv || 0,
|
||||
data.power_pv || 0,
|
||||
data.luminosity || 0
|
||||
data.power_pv || 0
|
||||
);
|
||||
|
||||
lastHeartbeat = Date.now();
|
||||
updateConnectionStatus(online);
|
||||
// On n'affiche une alerte que si les données sont récentes (sinon non fiable)
|
||||
updateAlert(online ? (data.battery_alert || null) : null);
|
||||
|
||||
} catch (error) {
|
||||
console.error("En attente de données capteurs...", error);
|
||||
updateConnectionStatus(false);
|
||||
updateAlert(null);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSystemStatus() {
|
||||
if (Date.now() - lastHeartbeat > TIMEOUT_MS) {
|
||||
statusDot.className = "status-dot rounded-circle pulse-offline";
|
||||
statusText.innerText = "HORS LIGNE";
|
||||
statusText.style.color = "#ef4444";
|
||||
} else {
|
||||
statusDot.className = "status-dot rounded-circle pulse-online";
|
||||
statusText.innerText = "EN LIGNE";
|
||||
statusText.style.color = "#e2e8f0";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-test').addEventListener('click', function () {
|
||||
isOnline = !isOnline;
|
||||
if (isOnline) {
|
||||
this.innerHTML = '<i class="bi bi-wifi-off"></i> Simuler Coupure';
|
||||
this.classList.replace('btn-outline-success', 'btn-outline-danger');
|
||||
lastHeartbeat = Date.now();
|
||||
} else {
|
||||
this.innerHTML = '<i class="bi bi-wifi"></i> Rétablir Connexion';
|
||||
this.classList.replace('btn-outline-danger', 'btn-outline-success');
|
||||
}
|
||||
});
|
||||
// =========================================================
|
||||
// Démarrage
|
||||
// =========================================================
|
||||
// État initial neutre : on n'affiche PAS "EN LIGNE" tant qu'on n'a rien confirmé.
|
||||
statusText.innerText = "Vérification…";
|
||||
statusText.style.color = "#94a3b8";
|
||||
statusDot.className = "status-dot rounded-circle";
|
||||
|
||||
createEnvChart();
|
||||
createEnergyChart();
|
||||
fetchRealWeather();
|
||||
fetchDatabaseData();
|
||||
fetchHistoryData();
|
||||
checkSystemStatus();
|
||||
|
||||
setInterval(fetchRealWeather, 900000);
|
||||
setInterval(fetchDatabaseData, 10000);
|
||||
setInterval(fetchHistoryData, 30000);
|
||||
setInterval(checkSystemStatus, 10000);
|
||||
fetchRealWeather();
|
||||
fetchLatest();
|
||||
fetchHistory();
|
||||
|
||||
setInterval(fetchRealWeather, 900000); // météo externe : toutes les 15 min
|
||||
setInterval(fetchLatest, POLL_LATEST_MS); // cartes + statut : toutes les 4 s
|
||||
setInterval(fetchHistory, POLL_HISTORY_MS); // graphiques : toutes les 8 s
|
||||
Reference in New Issue
Block a user