Update project echo files

This commit is contained in:
Anismahi
2026-03-19 07:28:11 +01:00
parent 93409b6f71
commit 17026018a9
14 changed files with 1397 additions and 223 deletions

BIN
.DS_Store vendored

Binary file not shown.

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# On part d'une version légère de Python
FROM python:3.10-slim
# On se place dans le dossier /app du conteneur
WORKDIR /app
# On installe les outils système nécessaires pour la base de données
RUN apt-get update && apt-get install -y libpq-dev gcc
# On copie d'abord le fichier des dépendances
COPY requirements.txt .
# On installe les bibliothèques Python
RUN pip install --no-cache-dir -r requirements.txt
# On copie tout le reste du projet (tes HTML, CSS, app.py, etc.)
COPY . .
# On expose le port 5000 pour ton Mac
EXPOSE 5000
# La commande pour démarrer le serveur
CMD ["python", "app.py"]

288
app.py Normal file
View File

@@ -0,0 +1,288 @@
from flask import Flask, jsonify, render_template, request, redirect, url_for, session
from flask_cors import CORS
from config import Config
import psycopg2
app = Flask(__name__)
app.config.from_object(Config)
app.secret_key = app.config["SECRET_KEY"]
CORS(app)
def get_connection():
return psycopg2.connect(
host=app.config["DB_HOST"],
port=app.config["DB_PORT"],
database=app.config["DB_NAME"],
user=app.config["DB_USER"],
password=app.config["DB_PASSWORD"]
)
def get_setting_value(cur, key, default_value):
cur.execute("SELECT value FROM settings WHERE key = %s", (key,))
row = cur.fetchone()
return row[0] if row else default_value
# ========================================================
# MODIFICATION ÉTUDIANT 3 : Sécurisation de l'accueil
# ========================================================
@app.route("/")
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")
def status():
return jsonify({
"project": "EcoCharge",
"api": "ok",
"database": "postgresql_connected"
})
@app.route("/api/data", methods=["POST"])
def receive_data():
data = request.get_json()
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()
cur = conn.cursor()
max_battery_temperature = float(get_setting_value(cur, "max_battery_temperature", "60"))
min_battery_voltage = float(get_setting_value(cur, "min_battery_voltage", "11"))
min_solar_power = float(get_setting_value(cur, "min_solar_power", "5"))
battery_alert = "none"
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"
cur.execute("""
INSERT INTO telemetry (
temperature_ext,
humidity_ext,
system_status_msg,
voltage_pv,
current_pv,
power_pv,
luminosity,
voltage_battery,
current_battery,
battery_temp,
battery_level,
battery_alert
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
temperature_ext,
humidity_ext,
system_status_msg,
voltage_pv,
current_pv,
power_pv,
luminosity,
voltage_battery,
current_battery,
battery_temp,
battery_level,
battery_alert
))
conn.commit()
cur.close()
conn.close()
return jsonify({
"message": "Données enregistrées avec succès",
"battery_alert": battery_alert
}), 201
@app.route("/api/latest", methods=["GET"])
def latest_data():
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT
id,
created_at,
temperature_ext,
humidity_ext,
system_status_msg,
voltage_pv,
current_pv,
power_pv,
luminosity,
voltage_battery,
current_battery,
battery_temp,
battery_level,
battery_alert
FROM telemetry
ORDER BY created_at DESC
LIMIT 1
""")
row = cur.fetchone()
cur.close()
conn.close()
if not row:
return jsonify({"message": "Aucune donnée disponible"}), 404
return jsonify({
"id": row[0],
"created_at": str(row[1]),
"temperature_ext": row[2],
"humidity_ext": row[3],
"system_status": row[4],
"voltage_pv": row[5],
"current_pv": row[6],
"power_pv": row[7],
"luminosity": row[8],
"voltage_battery": row[9],
"current_battery": row[10],
"battery_temp": row[11],
"battery_level": row[12],
"battery_alert": row[13]
})
@app.route("/admin/login", methods=["GET", "POST"])
def admin_login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
conn = get_connection()
cur = conn.cursor()
cur.execute(
"SELECT id FROM users WHERE username = %s AND password = %s AND is_admin = TRUE",
(username, password)
)
user = cur.fetchone()
cur.close()
conn.close()
if user:
session["admin_logged_in"] = True
# ========================================================
# MODIFICATION ÉTUDIANT 3 : Redirection vers le Dashboard
# ========================================================
return redirect(url_for("home"))
# ========================================================
return render_template("login.html", error="Identifiants incorrects")
return render_template("login.html")
@app.route("/admin/dashboard", methods=["GET", "POST"])
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")
cur.execute("""
INSERT INTO settings (key, value)
VALUES ('min_battery_voltage', %s)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value
""", (min_battery,))
cur.execute("""
INSERT INTO settings (key, value)
VALUES ('max_battery_temperature', %s)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value
""", (max_temp,))
cur.execute("""
INSERT INTO settings (key, value)
VALUES ('min_solar_power', %s)
ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value
""", (min_power,))
conn.commit()
settings = {
"min_battery": get_setting_value(cur, "min_battery_voltage", "11"),
"max_temp": get_setting_value(cur, "max_battery_temperature", "60"),
"min_power": get_setting_value(cur, "min_solar_power", "5")
}
cur.close()
conn.close()
return render_template("admin_dashboard.html", settings=settings)
@app.route("/admin/logout")
def admin_logout():
session.pop("admin_logged_in", None)
return redirect(url_for("admin_login"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

10
config.py Normal file
View File

@@ -0,0 +1,10 @@
import os
class Config:
# S'il trouve la variable Docker, il l'utilise. Sinon, il met "localhost" par défaut.
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = os.environ.get("DB_PORT", "5432")
DB_NAME = os.environ.get("DB_NAME", "ecocharge")
DB_USER = os.environ.get("DB_USER", "ecocharge_user")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "ecocharge_password")
SECRET_KEY = os.environ.get("SECRET_KEY", "cle_par_defaut")

46
database.sql Normal file
View File

@@ -0,0 +1,46 @@
DROP TABLE IF EXISTS telemetry CASCADE;
DROP TABLE IF EXISTS system_status CASCADE;
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE
);
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
key VARCHAR(50) UNIQUE NOT NULL,
value VARCHAR(100) NOT NULL
);
CREATE TABLE telemetry (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
temperature_ext DOUBLE PRECISION,
humidity_ext DOUBLE PRECISION,
system_status_msg VARCHAR(100),
voltage_pv DOUBLE PRECISION,
current_pv DOUBLE PRECISION,
power_pv DOUBLE PRECISION,
luminosity DOUBLE PRECISION,
voltage_battery DOUBLE PRECISION,
current_battery DOUBLE PRECISION,
battery_temp DOUBLE PRECISION,
battery_level DOUBLE PRECISION,
battery_alert VARCHAR(100)
);
INSERT INTO users (username, password, is_admin)
VALUES ('admin', 'admin123', TRUE);
INSERT INTO settings (key, value)
VALUES
('min_battery_voltage', '11'),
('max_battery_temperature', '60'),
('min_solar_power', '5');

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
# 1. LA BASE DE DONNÉES (PostgreSQL)
db:
image: postgres:15-alpine
container_name: ecocharge_db
environment:
POSTGRES_USER: ecocharge_user
POSTGRES_PASSWORD: ecocharge_password
POSTGRES_DB: ecocharge
ports:
- "5432:5432" # <-- Très important : ça te permet de t'y connecter avec DBeaver !
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
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
- DB_USER=ecocharge_user
- DB_PASSWORD=ecocharge_password
- SECRET_KEY=super_cle_secrete_projet
volumes:
postgres_data:

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
Flask==3.0.0
Flask-Cors==4.0.0
psycopg2-binary==2.9.9

115
script.js
View File

@@ -1,115 +0,0 @@
// --- VARIABLES D'ÉTAT ---
let isOnline = true;
let lastHeartbeat = Date.now();
const TIMEOUT_MS = 5000; // 5 secondes pour les tests
// --- ÉLÉMENTS DU DOM ---
const statusDot = document.getElementById('system-status');
const statusText = document.getElementById('status-text');
const tempEl = document.getElementById('temp-val');
const humEl = document.getElementById('hum-val');
const apiTempEl = document.getElementById('api-temp');
const apiDescEl = document.getElementById('api-desc');
const apiIconEl = document.getElementById('api-icon');
// =================================================================
// --- NOUVEAU : GESTION DE LA VRAIE API MÉTÉO (OPEN-METEO) ---
// =================================================================
// 1. Traduction des codes météo de l'API en Icônes et Textes
function getWeatherDetails(code) {
if (code === 0) return { desc: "Dégagé", icon: "bi-brightness-high", color: "text-warning" };
if (code === 1 || code === 2 || code === 3) return { desc: "Nuageux", icon: "bi-cloud", color: "text-light" };
if (code >= 45 && code <= 48) return { desc: "Brouillard", icon: "bi-cloud-haze", color: "text-secondary" };
if (code >= 51 && code <= 67) return { desc: "Pluie", icon: "bi-cloud-rain", color: "text-info" };
if (code >= 71 && code <= 77) return { desc: "Neige", icon: "bi-cloud-snow", color: "text-white" };
if (code >= 95) return { desc: "Orage", icon: "bi-cloud-lightning", color: "text-danger" };
return { desc: "Inconnu", icon: "bi-thermometer", color: "text-muted" };
}
// 2. Fonction qui va chercher les données sur Internet
async function fetchRealWeather() {
try {
// --- METS LES COORDONNÉES DE TA VILLE ICI ---
const lat = 48.8566; // Latitude (Ex: Paris)
const lon = 2.3522; // Longitude (Ex: Paris)
// L'URL magique de l'API Open-Meteo
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code`;
// On lance la requête
const response = await fetch(url);
const data = await response.json(); // On convertit la réponse en JSON
// On extrait la température et le code météo
const temp = data.current.temperature_2m;
const code = data.current.weather_code;
// On récupère la bonne icône et le bon texte
const weather = getWeatherDetails(code);
// On met à jour l'interface HTML !
apiTempEl.innerText = temp + "°C";
apiDescEl.innerText = weather.desc;
apiIconEl.className = "bi " + weather.icon + " " + weather.color + " display-4 me-3";
} catch (error) {
console.error("Erreur avec l'API Météo:", error);
apiDescEl.innerText = "Erreur Web";
apiIconEl.className = "bi bi-wifi-off text-danger display-4 me-3";
}
}
// =================================================================
// --- FONCTION DE MISE À JOUR DES DONNÉES LOCALES (Ton DHT11) ---
function fetchLocalData() {
if (!isOnline) return;
// Simulation de ton capteur DHT11 local
tempEl.innerText = (22 + (Math.random() * 2 - 1)).toFixed(1);
humEl.innerText = Math.floor(45 + Math.random() * 5);
lastHeartbeat = Date.now();
}
// --- FONCTION WATCHDOG (LE HEARTBEAT) ---
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";
}
}
// --- LANCEMENT DES PROGRAMMES ---
// 1. Lancer la météo Web une première fois tout de suite
fetchRealWeather();
// 2. Les boucles automatiques
setInterval(fetchLocalData, 3000); // Mise à jour de ton capteur local toutes les 3s
setInterval(checkSystemStatus, 1000); // Surveillance de la connexion
// ATTENTION: On ne met à jour la météo Web que toutes les 15 minutes (900 000 ms)
// pour ne pas saturer l'API gratuite et se faire bloquer !
setInterval(fetchRealWeather, 900000);
// --- GESTION DU BOUTON DE TEST ---
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');
}
});

202
static/script.js Normal file
View File

@@ -0,0 +1,202 @@
// --- 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');
const tempEl = document.getElementById('temp-val');
const humEl = document.getElementById('hum-val');
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 gaugeCircle = document.querySelector('.gauge-fill circle');
const halfCircumference = 283 / 2;
// =================================================================
// --- GESTION DE LA VRAIE API MÉTÉO WEB (Open-Meteo) ---
// =================================================================
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" };
if (code >= 45 && code <= 48) return { desc: "Brouillard", icon: "bi-cloud-haze", color: "text-secondary" };
if (code >= 51 && code <= 67) return { desc: "Pluie", icon: "bi-cloud-rain", color: "text-info" };
if (code >= 71 && code <= 77) return { desc: "Neige", icon: "bi-cloud-snow", color: "text-white" };
if (code >= 95) return { desc: "Orage", icon: "bi-cloud-lightning", color: "text-danger" };
return { desc: "Inconnu", icon: "bi-thermometer", color: "text-muted" };
}
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 url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code`;
const response = await fetch(url);
const data = await response.json();
const temp = data.current.temperature_2m;
const code = data.current.weather_code;
const weather = getWeatherDetails(code);
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) {
batValEl.innerText = percent + "%";
let color = "#34d399";
let shadowColor = "rgba(52, 211, 153, 0.4)";
if (percent <= 20) {
color = "#ef4444";
shadowColor = "rgba(239, 68, 68, 0.6)";
batAlertText.innerText = "Tension 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";
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) {
// 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";
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>';
// Animation de la jauge SVG
const ratio = Math.min(p / MAX_POWER, 1);
const offset = halfCircumference - (ratio * halfCircumference);
if (gaugeCircle) {
gaugeCircle.style.strokeDashoffset = offset;
}
}
// =================================================================
// --- NOUVEAU : CONNEXION AU SERVEUR PYTHON (BASE DE DONNÉES) ---
// =================================================================
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) {
throw new Error("Base de données vide ou serveur injoignable");
}
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));
}
// 3. Mise à jour du Solaire
updateSolar(
data.voltage_pv || 0,
data.current_pv || 0,
data.power_pv || 0,
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";
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";
}
}
// =================================================================
// --- 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) {
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');
}
});

248
static/style.css Normal file
View File

@@ -0,0 +1,248 @@
/* --- COULEURS GLOBALES ET THEME SOMBRE --- */
body {
background-color: #0f172a;
color: #e2e8f0;
}
.navbar {
background-color: #1e293b !important;
border-bottom: 1px solid #334155;
}
/* --- DESIGN DES WIDGETS (CARTES) --- */
.card {
background-color: #1e293b;
border: 1px solid #334155;
min-height: 320px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.card-meteo {
border-top: 4px solid #38bdf8;
}
.card-solaire {
border-top: 4px solid #fbbf24;
}
.card-batterie {
border-top: 4px solid #34d399;
}
.card-admin {
border-top: 4px solid #f87171;
}
/* --- ANIMATION DU HEARTBEAT PRINCIPAL --- */
.status-dot {
width: 14px;
height: 14px;
display: inline-block;
}
.pulse-online {
background-color: #22c55e;
box-shadow: 0 0 12px #22c55e;
animation: pulse-green 2s infinite;
}
.pulse-offline {
background-color: #ef4444;
box-shadow: 0 0 15px #ef4444;
}
@keyframes pulse-green {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
/* --- TYPOGRAPHIE ET ELEMENTS VISUELS --- */
.data-value {
color: #e0f2fe;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.3);
}
.placeholder-box {
border: 2px dashed #475569;
background-color: #0f172a;
color: #94a3b8;
}
/* =========================================
WIDGET BATTERIE (Code de l'Étudiant 2)
========================================= */
.battery-circle {
width: 140px;
height: 140px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: conic-gradient(#34d399 0deg, #0f172a 0deg);
box-shadow: 0 0 15px rgba(52, 211, 153, 0.3), inset 0 0 15px rgba(52, 211, 153, 0.2);
transition: background 0.5s ease, box-shadow 0.5s ease;
}
.battery-inner {
width: 110px;
height: 110px;
border-radius: 50%;
background: #1e293b;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.bat-percent {
font-size: 32px;
font-weight: bold;
color: #e0f2fe;
text-shadow: 0 0 10px rgba(52, 211, 153, 0.5);
line-height: 1;
}
/* =========================================
WIDGET SOLAIRE (Code de l'Étudiant 1 adapté et corrigé)
========================================= */
/* CORRECTION ICI : La boîte est maintenant carrée et ne déborde plus */
.gauge-container {
position: relative;
width: 160px;
height: 160px;
margin: 0 auto 15px;
}
.gauge-bg,
.gauge-fill {
position: absolute;
top: 0;
left: 0;
width: 160px;
height: 160px;
}
.gauge-bg circle,
.gauge-fill circle {
fill: none;
stroke-width: 18;
stroke-linecap: round;
}
.gauge-bg circle {
stroke: #0f172a;
}
.gauge-fill circle {
stroke: url(#gaugeGradient);
stroke-dasharray: 283;
stroke-dashoffset: 283;
transition: stroke-dashoffset 1s ease;
transform: rotate(180deg);
transform-origin: center;
}
/* CORRECTION ICI : Le texte est parfaitement centré au milieu du cercle */
.gauge-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
width: 100%;
}
.gauge-value .number {
font-size: 32px;
font-weight: 700;
color: #facc15;
line-height: 1;
}
.gauge-value .unit {
font-size: 14px;
color: #94a3b8;
font-weight: 600;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 10px;
}
.stat-label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.stat-value {
font-size: 16px;
font-weight: 700;
color: #e2e8f0;
}
.stat-value .stat-unit {
font-size: 11px;
color: #64748b;
font-weight: 600;
}
.sol-status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.15);
border-radius: 12px;
}
.sol-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
animation: blink-sol 1.5s ease-in-out infinite;
}
@keyframes blink-sol {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.sol-status-text {
font-size: 12px;
color: #22c55e;
font-weight: 600;
}

View File

@@ -1,81 +0,0 @@
/* --- COULEURS GLOBALES ET THEME SOMBRE --- */
body {
background-color: #0f172a;
color: #e2e8f0;
}
.navbar {
background-color: #1e293b !important;
border-bottom: 1px solid #334155;
}
/* --- DESIGN DES WIDGETS (CARTES) --- */
.card {
background-color: #1e293b;
border: 1px solid #334155;
min-height: 320px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.card-meteo {
border-top: 4px solid #38bdf8;
}
.card-solaire {
border-top: 4px solid #fbbf24;
}
.card-batterie {
border-top: 4px solid #34d399;
}
.card-admin {
border-top: 4px solid #f87171;
}
/* --- ANIMATION DU HEARTBEAT --- */
.status-dot {
width: 14px;
height: 14px;
display: inline-block;
}
.pulse-online {
background-color: #22c55e;
box-shadow: 0 0 12px #22c55e;
animation: pulse-green 2s infinite;
}
.pulse-offline {
background-color: #ef4444;
box-shadow: 0 0 15px #ef4444;
}
@keyframes pulse-green {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
/* --- TYPOGRAPHIE ET ELEMENTS VISUELS --- */
.data-value {
color: #e0f2fe;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.3);
}
.placeholder-box {
border: 2px dashed #475569;
background-color: #0f172a;
color: #94a3b8;
}

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EcoCharge — Dashboard Admin</title>
<style>
/* ── Variables de couleur ── */
:root {
--vert: #2ecc71;
--bleu: #1a1a2e;
--gris: #16213e;
--blanc: #f0f4f8;
--muted: #7f8c8d;
}
/* ── Reset simple ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: var(--bleu);
font-family: 'Segoe UI', sans-serif;
color: var(--blanc);
min-height: 100vh;
padding: 40px 20px;
}
/* ── Conteneur principal centré ── */
.conteneur {
max-width: 640px;
margin: 0 auto;
}
/* ── En-tête de la page ── */
.entete {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 36px;
}
.entete-titre {
display: flex;
align-items: center;
gap: 12px;
}
.entete-titre span {
font-size: 36px;
}
.entete-titre h1 {
font-size: 24px;
color: var(--vert);
letter-spacing: 2px;
}
.entete-titre p {
font-size: 13px;
color: var(--muted);
margin-top: 2px;
}
/* ── Lien de déconnexion ── */
.deconnexion {
color: var(--muted);
text-decoration: none;
font-size: 14px;
border: 1px solid #2c3e50;
padding: 8px 14px;
border-radius: 8px;
transition: color 0.2s, border-color 0.2s;
}
.deconnexion:hover {
color: #e74c3c;
border-color: #e74c3c;
}
/* ── Carte blanche contenant le formulaire ── */
.carte {
background-color: var(--gris);
border-radius: 16px;
padding: 36px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.carte h2 {
font-size: 17px;
color: var(--blanc);
margin-bottom: 6px;
}
.carte>p {
color: var(--muted);
font-size: 14px;
margin-bottom: 32px;
}
/* ── Séparateur entre les champs ── */
.separateur {
border: none;
border-top: 1px solid #2c3e50;
margin: 28px 0;
}
/* ── Chaque groupe label + input + description ── */
.champ {
margin-bottom: 28px;
}
.champ-entete {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
}
.champ label {
color: var(--blanc);
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
/* Petite description sous le label */
.champ-info {
color: var(--muted);
font-size: 12px;
}
/* Ligne input + unité */
.input-ligne {
display: flex;
align-items: center;
gap: 10px;
}
.champ input {
flex: 1;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #2c3e50;
background-color: #0f3460;
color: var(--blanc);
font-size: 16px;
outline: none;
transition: border-color 0.2s;
}
.champ input:focus {
border-color: var(--vert);
}
/* Unité affichée à droite (%, °C, W) */
.unite {
color: var(--muted);
font-size: 15px;
min-width: 24px;
}
/* ── Bouton Sauvegarder ── */
.bouton {
width: 100%;
padding: 14px;
background-color: var(--vert);
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
letter-spacing: 1px;
margin-top: 8px;
transition: background-color 0.2s;
}
.bouton:hover {
background-color: #27ae60;
}
/* ── Message de confirmation Flask/Jinja ── */
.succes {
background-color: rgba(46, 204, 113, 0.15);
border: 1px solid var(--vert);
color: var(--vert);
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 24px;
text-align: center;
}
</style>
</head>
<body>
<div class="conteneur">
<!-- ── En-tête avec titre et bouton déconnexion ── -->
<div class="entete">
<div class="entete-titre">
<span></span>
<div>
<h1>ECOCHARGE</h1>
<p>Panneau d'administration</p>
</div>
</div>
<!-- Lien vers la route Flask /admin/logout -->
<a class="deconnexion" href="/admin/logout">🚪 Déconnexion</a>
</div>
<!-- ── Carte principale ── -->
<div class="carte">
<h2>Seuils d'alerte</h2>
<p>Modifiez les valeurs puis cliquez sur "Sauvegarder".</p>
<!-- Formulaire — action vers la route Flask /admin/dashboard -->
<form method="POST" action="/admin/dashboard">
<!-- ── Champ 1 : batterie minimum ── -->
<div class="champ">
<div class="champ-entete">
<label for="min_battery">🔋 Tension batterie minimum</label>
</div>
<p class="champ-info">Alerte si la tension de la batterie descend sous ce seuil.</p>
<br>
<div class="input-ligne">
<input type="number" id="min_battery" name="min_battery" value="{{ settings.min_battery }}"
min="0" required>
<span class="unite">V</span>
</div>
</div>
<hr class="separateur">
<!-- ── Champ 2 : température maximum ── -->
<div class="champ">
<div class="champ-entete">
<label for="max_temp">🌡️ Température maximum</label>
</div>
<p class="champ-info">Alerte si la température dépasse cette valeur.</p>
<br>
<div class="input-ligne">
<input type="number" id="max_temp" name="max_temp" value="{{ settings.max_temp }}" min="0"
max="100" required>
<span class="unite">°C</span>
</div>
</div>
<hr class="separateur">
<!-- ── Champ 3 : puissance minimum ── -->
<div class="champ">
<div class="champ-entete">
<label for="min_power">☀️ Puissance minimum</label>
</div>
<p class="champ-info">Alerte si la production solaire descend sous ce seuil.</p>
<br>
<div class="input-ligne">
<input type="number" id="min_power" name="min_power" value="{{ settings.min_power }}" min="0"
required>
<span class="unite">W</span>
</div>
</div>
<button class="bouton" type="submit">💾 Sauvegarder</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -9,7 +9,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
@@ -78,48 +78,104 @@
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card card-solaire rounded-3"> <div class="card card-solaire rounded-3">
<div class="card-body p-4"> <div class="card-body p-4 d-flex flex-column">
<h5 class="card-title fw-bold mb-4" style="color: #fbbf24;"><i <h5 class="card-title fw-bold mb-4" style="color: #fbbf24;">
class="bi bi-sun me-2"></i>Production Solaire</h5> <i class="bi bi-sun me-2"></i>Production Solaire
<div class="d-flex h-100 align-items-center justify-content-center"> </h5>
<div
class="placeholder-box text-center p-3 rounded w-100 h-100 d-flex flex-column justify-content-center"> <div class="gauge-container mx-auto">
<i class="bi bi-code-slash d-block fs-2 mb-2 opacity-50"></i><span>Espace Étudiant <svg class="gauge-bg" viewBox="0 0 200 200">
1</span> <circle cx="100" cy="100" r="90" />
</svg>
<svg class="gauge-fill" viewBox="0 0 200 200">
<defs>
<linearGradient id="gaugeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f59e0b" />
<stop offset="100%" stop-color="#facc15" />
</linearGradient>
</defs>
<circle cx="100" cy="100" r="90" />
</svg>
<div class="gauge-value">
<span class="number" id="powerValue">0</span>
<span class="unit">W</span>
</div> </div>
</div> </div>
<div class="stats mt-auto">
<div class="stat-card">
<div class="stat-label">Tension (Upv)</div>
<div class="stat-value" id="voltage">0 <span class="stat-unit">V</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Courant (Ipv)</div>
<div class="stat-value" id="current">0 <span class="stat-unit">A</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Luminosité</div>
<div class="stat-value" id="lux">0 <span class="stat-unit">Lux</span></div>
</div>
<div class="stat-card">
<div class="stat-label">Rendement</div>
<div class="stat-value" id="efficiency">0 <span class="stat-unit">%</span></div>
</div>
</div>
<div class="sol-status-bar mt-3">
<div class="sol-status-dot"></div>
<span class="sol-status-text">Système connecté — MPPT actif</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card card-batterie rounded-3"> <div class="card card-batterie rounded-3">
<div class="card-body p-4"> <div class="card-body p-4 d-flex flex-column">
<h5 class="card-title fw-bold mb-4" style="color: #34d399;"><i <h5 class="card-title fw-bold mb-4" style="color: #34d399;">
class="bi bi-battery-charging me-2"></i>État Batterie</h5> <i class="bi bi-battery-charging me-2"></i>État Batterie
<div class="d-flex h-100 align-items-center justify-content-center"> </h5>
<div
class="placeholder-box text-center p-3 rounded w-100 h-100 d-flex flex-column justify-content-center"> <div class="d-flex flex-column align-items-center justify-content-center flex-grow-1">
<i class="bi bi-code-slash d-block fs-2 mb-2 opacity-50"></i><span>Espace Étudiant <div class="battery-circle" id="bat-circle">
2</span> <div class="battery-inner">
<span class="bat-percent" id="bat-val">--%</span>
<small class="text-secondary text-uppercase fw-bold">SoC</small>
</div>
</div> </div>
</div> </div>
<div class="mt-4 text-center">
<div class="badge bg-dark border border-secondary text-secondary w-100 py-2"
id="bat-alert-badge">
<i class="bi bi-check-circle text-success" id="bat-alert-icon"></i> <span
id="bat-alert">Système Normal</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-md-6 col-lg-3"> <div class="col-12 col-md-6 col-lg-3">
<div class="card card-admin rounded-3"> <div class="card card-admin rounded-3">
<div class="card-body p-4"> <div class="card-body p-4 d-flex flex-column">
<h5 class="card-title fw-bold mb-4" style="color: #f87171;"><i <h5 class="card-title fw-bold mb-4" style="color: #f87171;">
class="bi bi-sliders me-2"></i>Administration</h5> <i class="bi bi-sliders me-2"></i>Administration
<div class="d-flex h-100 align-items-center justify-content-center"> </h5>
<div
class="placeholder-box text-center p-3 rounded w-100 h-100 d-flex flex-column justify-content-center"> <div class="d-flex flex-column h-100 justify-content-center mt-auto gap-3">
<i class="bi bi-code-slash d-block fs-2 mb-2 opacity-50"></i><span>Espace Étudiant <a href="/admin/dashboard"
4</span> class="btn btn-outline-danger w-100 py-3 fw-bold rounded-3 d-flex flex-column align-items-center shadow-sm">
</div> <i class="bi bi-gear-fill display-4 mb-2"></i>
<span>Paramètres Système</span>
</a>
<a href="/admin/logout" class="btn btn-dark border-secondary w-100 text-secondary mt-2">
<i class="bi bi-box-arrow-right me-2"></i>Déconnexion
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -127,7 +183,7 @@
</div> </div>
</div> </div>
<script src="script.js"></script> <script src="{{ url_for('static', filename='script.js') }}"></script>
</body> </body>
</html> </html>

174
templates/login.html Normal file
View File

@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EcoCharge — Connexion</title>
<style>
/* ── Variables de couleur ── */
:root {
--vert: #2ecc71;
--bleu: #1a1a2e;
--gris: #16213e;
--blanc: #f0f4f8;
--rouge: #e74c3c;
}
/* ── Reset simple ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ── Fond de page ── */
body {
background-color: var(--bleu);
font-family: 'Segoe UI', sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Carte de connexion ── */
.carte {
background-color: var(--gris);
padding: 48px 40px;
border-radius: 16px;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* ── Logo / en-tête ── */
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo-icone {
font-size: 48px;
display: block;
margin-bottom: 8px;
}
.logo h1 {
color: var(--vert);
font-size: 26px;
letter-spacing: 2px;
}
.logo p {
color: #7f8c8d;
font-size: 13px;
margin-top: 4px;
}
/* ── Champs du formulaire ── */
.champ {
margin-bottom: 20px;
}
.champ label {
display: block;
color: var(--blanc);
font-size: 13px;
margin-bottom: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
}
.champ input {
width: 100%;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #2c3e50;
background-color: #0f3460;
color: var(--blanc);
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
/* Met en valeur le champ actif */
.champ input:focus {
border-color: var(--vert);
}
/* ── Message d'erreur Flask/Jinja ── */
.erreur {
background-color: rgba(231, 76, 60, 0.15);
border: 1px solid var(--rouge);
color: var(--rouge);
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 20px;
text-align: center;
}
/* ── Bouton de connexion ── */
.bouton {
width: 100%;
padding: 13px;
background-color: var(--vert);
color: #fff;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
letter-spacing: 1px;
transition: background-color 0.2s;
}
.bouton:hover {
background-color: #27ae60;
}
</style>
</head>
<body>
<div class="carte">
<!-- En-tête avec icône et nom du projet -->
<div class="logo">
<span class="logo-icone"></span>
<h1>ECOCHARGE</h1>
<p>Interface d'administration</p>
</div>
<!-- Message d'erreur affiché si Flask envoie une erreur -->
{% if error %}
<div class="erreur">
{{ error }}
</div>
{% endif %}
<!-- Formulaire de connexion — action vers la route Flask /admin/login -->
<form method="POST" action="/admin/login">
<div class="champ">
<label for="username">Identifiant</label>
<input type="text" id="username" name="username" placeholder="admin" required autocomplete="username">
</div>
<div class="champ">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" placeholder="••••••••" required
autocomplete="current-password">
</div>
<button class="bouton" type="submit">Se connecter</button>
</form>
</div>
</body>
</html>