diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d4329e --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.pyc +*.pyo + +# Virtual env +.venv/ + +# VS Code +.vscode/ + +# OS +.DS_Store + +# Logs +*.log + +# Docker +*.db \ No newline at end of file diff --git a/app.py b/app.py index 9a48576..5cee0e7 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,22 @@ from flask import Flask, jsonify, render_template, request, redirect, url_for, session from flask_cors import CORS from config import Config +from werkzeug.security import check_password_hash +from functools import wraps +from datetime import timedelta import psycopg2 app = Flask(__name__) app.config.from_object(Config) + app.secret_key = app.config["SECRET_KEY"] +app.config["SESSION_PERMANENT"] = True +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=2) +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = False + CORS(app) @@ -26,19 +36,24 @@ def get_setting_value(cur, key, default_value): return row[0] if row else default_value -# ======================================================== -# MODIFICATION ÉTUDIANT 3 : Sécurisation de l'accueil -# ======================================================== +def admin_required(view_func): + @wraps(view_func) + def wrapped_view(*args, **kwargs): + if not session.get("admin_logged_in"): + return redirect(url_for("admin_login")) + return view_func(*args, **kwargs) + return wrapped_view + + +@app.before_request +def make_session_permanent(): + session.permanent = True + + @app.route("/") +@admin_required def home(): - # Si l'utilisateur n'est pas connecté en tant qu'admin, - # on le force à aller sur la page de connexion. - if not session.get("admin_logged_in"): - return redirect(url_for("admin_login")) - - # S'il est connecté, on lui affiche le Dashboard. return render_template("index.html") -# ======================================================== @app.route("/api/status") @@ -52,32 +67,23 @@ def status(): @app.route("/api/data", methods=["POST"]) def receive_data(): - data = request.get_json() + data = request.get_json(silent=True) if not data: return jsonify({"error": "Aucune donnée JSON reçue"}), 400 - # Données environnement temperature_ext = data.get("temperature_ext") humidity_ext = data.get("humidity_ext") system_status_msg = data.get("system_status") - # Données panneau solaire voltage_pv = data.get("voltage_pv") current_pv = data.get("current_pv") - - # compatible solar_power power_pv = data.get("power_pv", data.get("solar_power")) - luminosity = data.get("luminosity") - # Données batterie voltage_battery = data.get("voltage_battery", data.get("battery_voltage")) current_battery = data.get("current_battery") - - # compatible battery_temperature battery_temp = data.get("battery_temp", data.get("battery_temperature")) - battery_level = data.get("battery_level") conn = get_connection() @@ -91,10 +97,8 @@ def receive_data(): if battery_temp is not None and float(battery_temp) > max_battery_temperature: battery_alert = "Température batterie trop élevée" - elif voltage_battery is not None and float(voltage_battery) < min_battery_voltage: battery_alert = "Tension batterie trop faible" - elif power_pv is not None and float(power_pv) < min_solar_power: battery_alert = "Puissance solaire insuffisante" @@ -140,7 +144,6 @@ def receive_data(): @app.route("/api/latest", methods=["GET"]) def latest_data(): - conn = get_connection() cur = conn.cursor() @@ -191,20 +194,69 @@ def latest_data(): }) +@app.route("/api/history", methods=["GET"]) +def history_data(): + limit = request.args.get("limit", default=10, type=int) + + if limit is None or limit <= 0: + limit = 10 + + if limit > 50: + limit = 50 + + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + id, + created_at, + temperature_ext, + humidity_ext, + power_pv, + battery_level + FROM telemetry + ORDER BY created_at DESC + LIMIT %s + """, (limit,)) + + rows = cur.fetchall() + + cur.close() + conn.close() + + history = [] + for row in reversed(rows): + history.append({ + "id": row[0], + "created_at": str(row[1]), + "temperature_ext": row[2], + "humidity_ext": row[3], + "power_pv": row[4], + "battery_level": row[5] + }) + + return jsonify(history) + + @app.route("/admin/login", methods=["GET", "POST"]) def admin_login(): + if session.get("admin_logged_in"): + return redirect(url_for("home")) if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") - username = request.form.get("username") - password = request.form.get("password") + if not username or not password: + return render_template("login.html", error="Veuillez remplir tous les champs") conn = get_connection() cur = conn.cursor() cur.execute( - "SELECT id FROM users WHERE username = %s AND password = %s AND is_admin = TRUE", - (username, password) + "SELECT id, username, password FROM users WHERE username = %s AND is_admin = TRUE", + (username,) ) user = cur.fetchone() @@ -213,13 +265,14 @@ def admin_login(): conn.close() if user: - session["admin_logged_in"] = True - - # ======================================================== - # MODIFICATION ÉTUDIANT 3 : Redirection vers le Dashboard - # ======================================================== - return redirect(url_for("home")) - # ======================================================== + user_id, db_username, password_hash = user + + if check_password_hash(password_hash, password): + session.clear() + session["admin_logged_in"] = True + session["admin_id"] = user_id + session["admin_username"] = db_username + return redirect(url_for("home")) return render_template("login.html", error="Identifiants incorrects") @@ -227,16 +280,12 @@ def admin_login(): @app.route("/admin/dashboard", methods=["GET", "POST"]) +@admin_required def admin_dashboard(): - - if not session.get("admin_logged_in"): - return redirect(url_for("admin_login")) - conn = get_connection() cur = conn.cursor() if request.method == "POST": - min_battery = request.form.get("min_battery") max_temp = request.form.get("max_temp") min_power = request.form.get("min_power") @@ -276,11 +325,10 @@ def admin_dashboard(): return render_template("admin_dashboard.html", settings=settings) -@app.route("/admin/logout") +@app.route("/admin/logout", methods=["GET", "POST"]) +@admin_required def admin_logout(): - - session.pop("admin_logged_in", None) - + session.clear() return redirect(url_for("admin_login")) diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..ea47a18 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,51 @@ +import psycopg2 +from werkzeug.security import generate_password_hash + +DB_HOST = "localhost" +DB_PORT = "5432" +DB_NAME = "ecocharge" +DB_USER = "ecocharge_user" +DB_PASSWORD = "ecocharge_password" + +USERNAME = "admin" +PLAIN_PASSWORD = "admin123" + +def main(): + password_hash = generate_password_hash(PLAIN_PASSWORD) + + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + cur = conn.cursor() + + cur.execute("SELECT id FROM users WHERE username = %s", (USERNAME,)) + user = cur.fetchone() + + if user: + cur.execute(""" + UPDATE users + SET password = %s, is_admin = TRUE + WHERE username = %s + """, (password_hash, USERNAME)) + print(f"Utilisateur '{USERNAME}' mis à jour avec un mot de passe haché.") + else: + cur.execute(""" + INSERT INTO users (username, password, is_admin) + VALUES (%s, %s, TRUE) + """, (USERNAME, password_hash)) + print(f"Utilisateur '{USERNAME}' créé avec un mot de passe haché.") + + conn.commit() + cur.close() + conn.close() + + print("Terminé.") + print(f"Nom d'utilisateur : {USERNAME}") + print(f"Mot de passe : {PLAIN_PASSWORD}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/database.sql b/database.sql index 416e784..f0b034e 100644 --- a/database.sql +++ b/database.sql @@ -36,11 +36,10 @@ CREATE TABLE telemetry ( battery_alert VARCHAR(100) ); -INSERT INTO users (username, password, is_admin) -VALUES ('admin', 'admin123', TRUE); +CREATE INDEX idx_telemetry_created_at ON telemetry(created_at DESC); INSERT INTO settings (key, value) VALUES ('min_battery_voltage', '11'), ('max_battery_temperature', '60'), - ('min_solar_power', '5'); + ('min_solar_power', '5'); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cf9dd0e..1ecd91d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,4 @@ -version: '3.8' - services: - # 1. LA BASE DE DONNÉES (PostgreSQL) db: image: postgres:15-alpine container_name: ecocharge_db @@ -10,22 +7,19 @@ services: POSTGRES_PASSWORD: ecocharge_password POSTGRES_DB: ecocharge ports: - - "5432:5432" # <-- Très important : ça te permet de t'y connecter avec DBeaver ! + - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - # La ligne magique : Docker va lire ton database.sql et créer les tables tout seul au premier lancement ! - ./database.sql:/docker-entrypoint-initdb.d/init.sql - # 2. TON SERVEUR WEB (Flask) web: build: . container_name: ecocharge_web ports: - - "5001:5000" # Ton Dashboard sera sur http://localhost:5000 + - "5001:5000" depends_on: - db environment: - # On donne les identifiants au serveur Python pour qu'il trouve la BDD - DB_HOST=db - DB_PORT=5432 - DB_NAME=ecocharge diff --git a/static/script.js b/static/script.js index a314bbc..0c85eaa 100644 --- a/static/script.js +++ b/static/script.js @@ -1,9 +1,7 @@ -// --- VARIABLES D'ÉTAT --- let isOnline = true; let lastHeartbeat = Date.now(); const TIMEOUT_MS = 5000; -// --- ÉLÉMENTS DU DOM PRINCIPAUX --- const statusDot = document.getElementById('system-status'); const statusText = document.getElementById('status-text'); @@ -14,21 +12,19 @@ const apiTempEl = document.getElementById('api-temp'); const apiDescEl = document.getElementById('api-desc'); const apiIconEl = document.getElementById('api-icon'); -// --- ÉLÉMENTS DOM BATTERIE (Étudiant 2) --- 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'); -// --- CONSTANTES SOLAIRE (Étudiant 1) --- -const MAX_POWER = 100; // Puissance maximum du panneau en Watts (à adapter si besoin) +const MAX_POWER = 100; const gaugeCircle = document.querySelector('.gauge-fill circle'); const halfCircumference = 283 / 2; -// ================================================================= -// --- GESTION DE LA VRAIE API MÉTÉO WEB (Open-Meteo) --- -// ================================================================= +let envChart = null; +let energyChart = null; + 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" }; @@ -41,8 +37,8 @@ function getWeatherDetails(code) { async function fetchRealWeather() { try { - const lat = 48.8566; // Tu peux changer avec la latitude de ta ville - const lon = 2.3522; // Tu peux changer avec la longitude de ta ville + const lat = 48.8566; + const lon = 2.3522; const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code`; const response = await fetch(url); @@ -55,31 +51,32 @@ async function fetchRealWeather() { apiTempEl.innerText = temp + "°C"; apiDescEl.innerText = weather.desc; apiIconEl.className = "bi " + weather.icon + " " + weather.color + " display-4 me-3"; - } catch (error) { console.error("Erreur Météo:", error); } } -// ================================================================= -// --- MISE À JOUR VISUELLE DES WIDGETS --- -// ================================================================= - -function updateBattery(percent) { +function updateBattery(percent, alertTextFromApi = null) { batValEl.innerText = percent + "%"; let color = "#34d399"; let shadowColor = "rgba(52, 211, 153, 0.4)"; - if (percent <= 20) { + if (alertTextFromApi && alertTextFromApi !== "none") { color = "#ef4444"; shadowColor = "rgba(239, 68, 68, 0.6)"; - batAlertText.innerText = "Tension Critique !"; + 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"; + batAlertText.innerText = "Niveau moyen"; batAlertIcon.className = "bi bi-exclamation-circle text-warning"; batAlertBadge.className = "badge bg-dark border border-warning text-secondary w-100 py-2"; } else { @@ -95,14 +92,12 @@ function updateBattery(percent) { } function updateSolar(voltage, current, power, lux) { - // On s'assure que ce sont des nombres et on les formate const v = Number(voltage).toFixed(1); const c = Number(current).toFixed(2); const p = Number(power).toFixed(1); const l = Math.floor(Number(lux)); - // Le serveur de l'étudiant 4 ne stocke pas l'efficacité, on simule ~15% quand il y a du soleil - const efficiency = (p > 0) ? (14 + Math.random() * 2).toFixed(1) : "0.0"; + const efficiency = (p > 0) ? ((p / MAX_POWER) * 100).toFixed(1) : "0.0"; document.getElementById('powerValue').textContent = p; document.getElementById('voltage').innerHTML = v + ' V'; @@ -110,7 +105,6 @@ function updateSolar(voltage, current, power, lux) { document.getElementById('lux').innerHTML = l.toLocaleString() + ' Lux'; document.getElementById('efficiency').innerHTML = efficiency + ' %'; - // Animation de la jauge SVG const ratio = Math.min(p / MAX_POWER, 1); const offset = halfCircumference - (ratio * halfCircumference); @@ -119,15 +113,182 @@ function updateSolar(voltage, current, power, lux) { } } -// ================================================================= -// --- NOUVEAU : CONNEXION AU SERVEUR PYTHON (BASE DE DONNÉES) --- -// ================================================================= +function createEnvChart() { + const ctx = document.getElementById('envChart'); + if (!ctx) return; + + envChart = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Température (°C)', + data: [], + borderColor: '#38bdf8', + backgroundColor: 'rgba(56, 189, 248, 0.2)', + pointBackgroundColor: '#38bdf8', + pointBorderColor: '#38bdf8', + borderWidth: 2, + tension: 0.3 + }, + { + label: 'Humidité (%)', + data: [], + borderColor: '#ff5c8a', + backgroundColor: 'rgba(255, 92, 138, 0.2)', + pointBackgroundColor: '#ff5c8a', + pointBorderColor: '#ff5c8a', + borderWidth: 2, + tension: 0.3 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + labels: { + color: '#e2e8f0' + } + } + }, + scales: { + x: { + ticks: { + color: '#94a3b8' + }, + grid: { + color: 'rgba(148, 163, 184, 0.1)' + } + }, + y: { + ticks: { + color: '#94a3b8' + }, + grid: { + color: 'rgba(148, 163, 184, 0.1)' + } + } + } + } + }); +} + +function createEnergyChart() { + const ctx = document.getElementById('energyChart'); + if (!ctx) return; + + energyChart = new Chart(ctx, { + type: 'line', + data: { + 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)', + data: [], + borderColor: '#facc15', + backgroundColor: 'rgba(250, 204, 21, 0.2)', + pointBackgroundColor: '#facc15', + pointBorderColor: '#facc15', + borderWidth: 2, + tension: 0.3 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + labels: { + color: '#e2e8f0' + } + } + }, + scales: { + x: { + ticks: { + color: '#94a3b8' + }, + grid: { + color: 'rgba(148, 163, 184, 0.1)' + } + }, + y: { + ticks: { + color: '#94a3b8' + }, + grid: { + color: 'rgba(148, 163, 184, 0.1)' + } + } + } + } + }); +} + +async function fetchHistoryData() { + try { + const response = await fetch('/api/history?limit=10'); + + 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', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }); + + if (envChart) { + 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(); + } + + 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(); + } + + } catch (error) { + console.error("Erreur historique :", error); + } +} async function fetchDatabaseData() { if (!isOnline) return; try { - // On interroge l'URL magique de l'étudiant 4 const response = await fetch('/api/latest'); if (!response.ok) { @@ -136,16 +297,13 @@ async function fetchDatabaseData() { const data = await response.json(); - // 1. Mise à jour de ton capteur local DHT11 tempEl.innerText = data.temperature_ext !== null ? Number(data.temperature_ext).toFixed(1) : "--"; humEl.innerText = data.humidity_ext !== null ? Math.floor(data.humidity_ext) : "--"; - // 2. Mise à jour de la Batterie if (data.battery_level !== null) { - updateBattery(Math.floor(data.battery_level)); + updateBattery(Math.floor(data.battery_level), data.battery_alert); } - // 3. Mise à jour du Solaire updateSolar( data.voltage_pv || 0, data.current_pv || 0, @@ -153,15 +311,12 @@ async function fetchDatabaseData() { data.luminosity || 0 ); - // Si on arrive ici, c'est qu'on a bien communiqué avec le serveur : on met à jour le Heartbeat lastHeartbeat = Date.now(); - } catch (error) { console.error("En attente de données capteurs...", error); } } -// --- FONCTION WATCHDOG --- function checkSystemStatus() { if (Date.now() - lastHeartbeat > TIMEOUT_MS) { statusDot.className = "status-dot rounded-circle pulse-offline"; @@ -174,21 +329,6 @@ function checkSystemStatus() { } } -// ================================================================= -// --- LANCEMENT DES BOUCLES --- -// ================================================================= - -// Météo Web (1 fois au lancement, puis toutes les 15 min) -fetchRealWeather(); -setInterval(fetchRealWeather, 900000); - -// Base de données locale (toutes les 3 secondes) -setInterval(fetchDatabaseData, 3000); - -// Watchdog (toutes les secondes) -setInterval(checkSystemStatus, 1000); - -// Bouton de simulation de panne document.getElementById('btn-test').addEventListener('click', function () { isOnline = !isOnline; if (isOnline) { @@ -199,4 +339,16 @@ document.getElementById('btn-test').addEventListener('click', function () { this.innerHTML = ' Rétablir Connexion'; this.classList.replace('btn-outline-danger', 'btn-outline-success'); } -}); \ No newline at end of file +}); + +createEnvChart(); +createEnergyChart(); +fetchRealWeather(); +fetchDatabaseData(); +fetchHistoryData(); +checkSystemStatus(); + +setInterval(fetchRealWeather, 900000); +setInterval(fetchDatabaseData, 3000); +setInterval(fetchHistoryData, 5000); +setInterval(checkSystemStatus, 1000); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index c2749cb..e90d135 100644 --- a/templates/index.html +++ b/templates/index.html @@ -8,7 +8,6 @@ - @@ -22,8 +21,7 @@
- Système en - ligne + Système en ligne
@@ -46,8 +44,7 @@

--°C

- En - attente... + En attente...
@@ -148,11 +145,10 @@
- Système Normal + + Système Normal
- @@ -175,7 +171,32 @@ Déconnexion + + + +
+
+
+
+ Historique environnement +
+
+ +
+
+
+
+ +
+
+
+
+ Historique énergie +
+
+ +
@@ -183,6 +204,7 @@ +