Update project echo files
This commit is contained in:
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
288
app.py
Normal 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
10
config.py
Normal 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
46
database.sql
Normal 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
37
docker-compose.yml
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Flask==3.0.0
|
||||
Flask-Cors==4.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
115
script.js
115
script.js
@@ -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}¤t=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
202
static/script.js
Normal 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}¤t=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
248
static/style.css
Normal 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;
|
||||
}
|
||||
81
style.css
81
style.css
@@ -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;
|
||||
}
|
||||
283
templates/admin_dashboard.html
Normal file
283
templates/admin_dashboard.html
Normal 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>
|
||||
@@ -9,7 +9,7 @@
|
||||
<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="style.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -78,48 +78,104 @@
|
||||
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card card-solaire rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title fw-bold mb-4" style="color: #fbbf24;"><i
|
||||
class="bi bi-sun me-2"></i>Production Solaire</h5>
|
||||
<div class="d-flex h-100 align-items-center justify-content-center">
|
||||
<div
|
||||
class="placeholder-box text-center p-3 rounded w-100 h-100 d-flex flex-column justify-content-center">
|
||||
<i class="bi bi-code-slash d-block fs-2 mb-2 opacity-50"></i><span>Espace Étudiant
|
||||
1</span>
|
||||
<div class="card-body p-4 d-flex flex-column">
|
||||
<h5 class="card-title fw-bold mb-4" style="color: #fbbf24;">
|
||||
<i class="bi bi-sun me-2"></i>Production Solaire
|
||||
</h5>
|
||||
|
||||
<div class="gauge-container mx-auto">
|
||||
<svg class="gauge-bg" viewBox="0 0 200 200">
|
||||
<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 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 class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card card-batterie rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title fw-bold mb-4" style="color: #34d399;"><i
|
||||
class="bi bi-battery-charging me-2"></i>État Batterie</h5>
|
||||
<div class="d-flex h-100 align-items-center justify-content-center">
|
||||
<div
|
||||
class="placeholder-box text-center p-3 rounded w-100 h-100 d-flex flex-column justify-content-center">
|
||||
<i class="bi bi-code-slash d-block fs-2 mb-2 opacity-50"></i><span>Espace Étudiant
|
||||
2</span>
|
||||
<div class="card-body p-4 d-flex flex-column">
|
||||
<h5 class="card-title fw-bold mb-4" style="color: #34d399;">
|
||||
<i class="bi bi-battery-charging me-2"></i>État Batterie
|
||||
</h5>
|
||||
|
||||
<div class="d-flex flex-column align-items-center justify-content-center flex-grow-1">
|
||||
<div class="battery-circle" id="bat-circle">
|
||||
<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 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 class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card card-admin rounded-3">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title fw-bold mb-4" style="color: #f87171;"><i
|
||||
class="bi bi-sliders me-2"></i>Administration</h5>
|
||||
<div class="d-flex h-100 align-items-center justify-content-center">
|
||||
<div
|
||||
class="placeholder-box text-center p-3 rounded w-100 h-100 d-flex flex-column justify-content-center">
|
||||
<i class="bi bi-code-slash d-block fs-2 mb-2 opacity-50"></i><span>Espace Étudiant
|
||||
4</span>
|
||||
</div>
|
||||
<div class="card-body p-4 d-flex flex-column">
|
||||
<h5 class="card-title fw-bold mb-4" style="color: #f87171;">
|
||||
<i class="bi bi-sliders me-2"></i>Administration
|
||||
</h5>
|
||||
|
||||
<div class="d-flex flex-column h-100 justify-content-center mt-auto gap-3">
|
||||
<a href="/admin/dashboard"
|
||||
class="btn btn-outline-danger w-100 py-3 fw-bold rounded-3 d-flex flex-column align-items-center shadow-sm">
|
||||
<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>
|
||||
@@ -127,7 +183,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
174
templates/login.html
Normal file
174
templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user