Initial commit projet IoT Flask ESP32
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Virtual env
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.db
|
||||||
128
app.py
128
app.py
@@ -1,12 +1,22 @@
|
|||||||
from flask import Flask, jsonify, render_template, request, redirect, url_for, session
|
from flask import Flask, jsonify, render_template, request, redirect, url_for, session
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from config import Config
|
from config import Config
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import timedelta
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
app.secret_key = app.config["SECRET_KEY"]
|
app.secret_key = app.config["SECRET_KEY"]
|
||||||
|
|
||||||
|
app.config["SESSION_PERMANENT"] = True
|
||||||
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=2)
|
||||||
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||||
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
app.config["SESSION_COOKIE_SECURE"] = False
|
||||||
|
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -26,19 +36,24 @@ def get_setting_value(cur, key, default_value):
|
|||||||
return row[0] if row else default_value
|
return row[0] if row else default_value
|
||||||
|
|
||||||
|
|
||||||
# ========================================================
|
def admin_required(view_func):
|
||||||
# MODIFICATION ÉTUDIANT 3 : Sécurisation de l'accueil
|
@wraps(view_func)
|
||||||
# ========================================================
|
def wrapped_view(*args, **kwargs):
|
||||||
@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"):
|
if not session.get("admin_logged_in"):
|
||||||
return redirect(url_for("admin_login"))
|
return redirect(url_for("admin_login"))
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
# S'il est connecté, on lui affiche le Dashboard.
|
|
||||||
|
@app.before_request
|
||||||
|
def make_session_permanent():
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@admin_required
|
||||||
|
def home():
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
# ========================================================
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/status")
|
@app.route("/api/status")
|
||||||
@@ -52,32 +67,23 @@ def status():
|
|||||||
|
|
||||||
@app.route("/api/data", methods=["POST"])
|
@app.route("/api/data", methods=["POST"])
|
||||||
def receive_data():
|
def receive_data():
|
||||||
data = request.get_json()
|
data = request.get_json(silent=True)
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "Aucune donnée JSON reçue"}), 400
|
return jsonify({"error": "Aucune donnée JSON reçue"}), 400
|
||||||
|
|
||||||
# Données environnement
|
|
||||||
temperature_ext = data.get("temperature_ext")
|
temperature_ext = data.get("temperature_ext")
|
||||||
humidity_ext = data.get("humidity_ext")
|
humidity_ext = data.get("humidity_ext")
|
||||||
system_status_msg = data.get("system_status")
|
system_status_msg = data.get("system_status")
|
||||||
|
|
||||||
# Données panneau solaire
|
|
||||||
voltage_pv = data.get("voltage_pv")
|
voltage_pv = data.get("voltage_pv")
|
||||||
current_pv = data.get("current_pv")
|
current_pv = data.get("current_pv")
|
||||||
|
|
||||||
# compatible solar_power
|
|
||||||
power_pv = data.get("power_pv", data.get("solar_power"))
|
power_pv = data.get("power_pv", data.get("solar_power"))
|
||||||
|
|
||||||
luminosity = data.get("luminosity")
|
luminosity = data.get("luminosity")
|
||||||
|
|
||||||
# Données batterie
|
|
||||||
voltage_battery = data.get("voltage_battery", data.get("battery_voltage"))
|
voltage_battery = data.get("voltage_battery", data.get("battery_voltage"))
|
||||||
current_battery = data.get("current_battery")
|
current_battery = data.get("current_battery")
|
||||||
|
|
||||||
# compatible battery_temperature
|
|
||||||
battery_temp = data.get("battery_temp", data.get("battery_temperature"))
|
battery_temp = data.get("battery_temp", data.get("battery_temperature"))
|
||||||
|
|
||||||
battery_level = data.get("battery_level")
|
battery_level = data.get("battery_level")
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -91,10 +97,8 @@ def receive_data():
|
|||||||
|
|
||||||
if battery_temp is not None and float(battery_temp) > max_battery_temperature:
|
if battery_temp is not None and float(battery_temp) > max_battery_temperature:
|
||||||
battery_alert = "Température batterie trop élevée"
|
battery_alert = "Température batterie trop élevée"
|
||||||
|
|
||||||
elif voltage_battery is not None and float(voltage_battery) < min_battery_voltage:
|
elif voltage_battery is not None and float(voltage_battery) < min_battery_voltage:
|
||||||
battery_alert = "Tension batterie trop faible"
|
battery_alert = "Tension batterie trop faible"
|
||||||
|
|
||||||
elif power_pv is not None and float(power_pv) < min_solar_power:
|
elif power_pv is not None and float(power_pv) < min_solar_power:
|
||||||
battery_alert = "Puissance solaire insuffisante"
|
battery_alert = "Puissance solaire insuffisante"
|
||||||
|
|
||||||
@@ -140,7 +144,6 @@ def receive_data():
|
|||||||
|
|
||||||
@app.route("/api/latest", methods=["GET"])
|
@app.route("/api/latest", methods=["GET"])
|
||||||
def latest_data():
|
def latest_data():
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -191,20 +194,69 @@ def latest_data():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/history", methods=["GET"])
|
||||||
|
def history_data():
|
||||||
|
limit = request.args.get("limit", default=10, type=int)
|
||||||
|
|
||||||
|
if limit is None or limit <= 0:
|
||||||
|
limit = 10
|
||||||
|
|
||||||
|
if limit > 50:
|
||||||
|
limit = 50
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
temperature_ext,
|
||||||
|
humidity_ext,
|
||||||
|
power_pv,
|
||||||
|
battery_level
|
||||||
|
FROM telemetry
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for row in reversed(rows):
|
||||||
|
history.append({
|
||||||
|
"id": row[0],
|
||||||
|
"created_at": str(row[1]),
|
||||||
|
"temperature_ext": row[2],
|
||||||
|
"humidity_ext": row[3],
|
||||||
|
"power_pv": row[4],
|
||||||
|
"battery_level": row[5]
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(history)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin/login", methods=["GET", "POST"])
|
@app.route("/admin/login", methods=["GET", "POST"])
|
||||||
def admin_login():
|
def admin_login():
|
||||||
|
if session.get("admin_logged_in"):
|
||||||
|
return redirect(url_for("home"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
username = request.form.get("username")
|
if not username or not password:
|
||||||
password = request.form.get("password")
|
return render_template("login.html", error="Veuillez remplir tous les champs")
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id FROM users WHERE username = %s AND password = %s AND is_admin = TRUE",
|
"SELECT id, username, password FROM users WHERE username = %s AND is_admin = TRUE",
|
||||||
(username, password)
|
(username,)
|
||||||
)
|
)
|
||||||
|
|
||||||
user = cur.fetchone()
|
user = cur.fetchone()
|
||||||
@@ -213,13 +265,14 @@ def admin_login():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
session["admin_logged_in"] = True
|
user_id, db_username, password_hash = user
|
||||||
|
|
||||||
# ========================================================
|
if check_password_hash(password_hash, password):
|
||||||
# MODIFICATION ÉTUDIANT 3 : Redirection vers le Dashboard
|
session.clear()
|
||||||
# ========================================================
|
session["admin_logged_in"] = True
|
||||||
|
session["admin_id"] = user_id
|
||||||
|
session["admin_username"] = db_username
|
||||||
return redirect(url_for("home"))
|
return redirect(url_for("home"))
|
||||||
# ========================================================
|
|
||||||
|
|
||||||
return render_template("login.html", error="Identifiants incorrects")
|
return render_template("login.html", error="Identifiants incorrects")
|
||||||
|
|
||||||
@@ -227,16 +280,12 @@ def admin_login():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/dashboard", methods=["GET", "POST"])
|
@app.route("/admin/dashboard", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
|
|
||||||
if not session.get("admin_logged_in"):
|
|
||||||
return redirect(url_for("admin_login"))
|
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|
||||||
min_battery = request.form.get("min_battery")
|
min_battery = request.form.get("min_battery")
|
||||||
max_temp = request.form.get("max_temp")
|
max_temp = request.form.get("max_temp")
|
||||||
min_power = request.form.get("min_power")
|
min_power = request.form.get("min_power")
|
||||||
@@ -276,11 +325,10 @@ def admin_dashboard():
|
|||||||
return render_template("admin_dashboard.html", settings=settings)
|
return render_template("admin_dashboard.html", settings=settings)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin/logout")
|
@app.route("/admin/logout", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
def admin_logout():
|
def admin_logout():
|
||||||
|
session.clear()
|
||||||
session.pop("admin_logged_in", None)
|
|
||||||
|
|
||||||
return redirect(url_for("admin_login"))
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
create_admin.py
Normal file
51
create_admin.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import psycopg2
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
DB_HOST = "localhost"
|
||||||
|
DB_PORT = "5432"
|
||||||
|
DB_NAME = "ecocharge"
|
||||||
|
DB_USER = "ecocharge_user"
|
||||||
|
DB_PASSWORD = "ecocharge_password"
|
||||||
|
|
||||||
|
USERNAME = "admin"
|
||||||
|
PLAIN_PASSWORD = "admin123"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
password_hash = generate_password_hash(PLAIN_PASSWORD)
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
database=DB_NAME,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT id FROM users WHERE username = %s", (USERNAME,))
|
||||||
|
user = cur.fetchone()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE users
|
||||||
|
SET password = %s, is_admin = TRUE
|
||||||
|
WHERE username = %s
|
||||||
|
""", (password_hash, USERNAME))
|
||||||
|
print(f"Utilisateur '{USERNAME}' mis à jour avec un mot de passe haché.")
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO users (username, password, is_admin)
|
||||||
|
VALUES (%s, %s, TRUE)
|
||||||
|
""", (USERNAME, password_hash))
|
||||||
|
print(f"Utilisateur '{USERNAME}' créé avec un mot de passe haché.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("Terminé.")
|
||||||
|
print(f"Nom d'utilisateur : {USERNAME}")
|
||||||
|
print(f"Mot de passe : {PLAIN_PASSWORD}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -36,8 +36,7 @@ CREATE TABLE telemetry (
|
|||||||
battery_alert VARCHAR(100)
|
battery_alert VARCHAR(100)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO users (username, password, is_admin)
|
CREATE INDEX idx_telemetry_created_at ON telemetry(created_at DESC);
|
||||||
VALUES ('admin', 'admin123', TRUE);
|
|
||||||
|
|
||||||
INSERT INTO settings (key, value)
|
INSERT INTO settings (key, value)
|
||||||
VALUES
|
VALUES
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# 1. LA BASE DE DONNÉES (PostgreSQL)
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: ecocharge_db
|
container_name: ecocharge_db
|
||||||
@@ -10,22 +7,19 @@ services:
|
|||||||
POSTGRES_PASSWORD: ecocharge_password
|
POSTGRES_PASSWORD: ecocharge_password
|
||||||
POSTGRES_DB: ecocharge
|
POSTGRES_DB: ecocharge
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432" # <-- Très important : ça te permet de t'y connecter avec DBeaver !
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- 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
|
- ./database.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
|
||||||
# 2. TON SERVEUR WEB (Flask)
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
container_name: ecocharge_web
|
container_name: ecocharge_web
|
||||||
ports:
|
ports:
|
||||||
- "5001:5000" # Ton Dashboard sera sur http://localhost:5000
|
- "5001:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
# On donne les identifiants au serveur Python pour qu'il trouve la BDD
|
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_NAME=ecocharge
|
- DB_NAME=ecocharge
|
||||||
|
|||||||
250
static/script.js
250
static/script.js
@@ -1,9 +1,7 @@
|
|||||||
// --- VARIABLES D'ÉTAT ---
|
|
||||||
let isOnline = true;
|
let isOnline = true;
|
||||||
let lastHeartbeat = Date.now();
|
let lastHeartbeat = Date.now();
|
||||||
const TIMEOUT_MS = 5000;
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
// --- ÉLÉMENTS DU DOM PRINCIPAUX ---
|
|
||||||
const statusDot = document.getElementById('system-status');
|
const statusDot = document.getElementById('system-status');
|
||||||
const statusText = document.getElementById('status-text');
|
const statusText = document.getElementById('status-text');
|
||||||
|
|
||||||
@@ -14,21 +12,19 @@ const apiTempEl = document.getElementById('api-temp');
|
|||||||
const apiDescEl = document.getElementById('api-desc');
|
const apiDescEl = document.getElementById('api-desc');
|
||||||
const apiIconEl = document.getElementById('api-icon');
|
const apiIconEl = document.getElementById('api-icon');
|
||||||
|
|
||||||
// --- ÉLÉMENTS DOM BATTERIE (Étudiant 2) ---
|
|
||||||
const batValEl = document.getElementById('bat-val');
|
const batValEl = document.getElementById('bat-val');
|
||||||
const batCircleEl = document.getElementById('bat-circle');
|
const batCircleEl = document.getElementById('bat-circle');
|
||||||
const batAlertBadge = document.getElementById('bat-alert-badge');
|
const batAlertBadge = document.getElementById('bat-alert-badge');
|
||||||
const batAlertIcon = document.getElementById('bat-alert-icon');
|
const batAlertIcon = document.getElementById('bat-alert-icon');
|
||||||
const batAlertText = document.getElementById('bat-alert');
|
const batAlertText = document.getElementById('bat-alert');
|
||||||
|
|
||||||
// --- CONSTANTES SOLAIRE (Étudiant 1) ---
|
const MAX_POWER = 100;
|
||||||
const MAX_POWER = 100; // Puissance maximum du panneau en Watts (à adapter si besoin)
|
|
||||||
const gaugeCircle = document.querySelector('.gauge-fill circle');
|
const gaugeCircle = document.querySelector('.gauge-fill circle');
|
||||||
const halfCircumference = 283 / 2;
|
const halfCircumference = 283 / 2;
|
||||||
|
|
||||||
// =================================================================
|
let envChart = null;
|
||||||
// --- GESTION DE LA VRAIE API MÉTÉO WEB (Open-Meteo) ---
|
let energyChart = null;
|
||||||
// =================================================================
|
|
||||||
function getWeatherDetails(code) {
|
function getWeatherDetails(code) {
|
||||||
if (code === 0) return { desc: "Dégagé", icon: "bi-brightness-high", color: "text-warning" };
|
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 >= 1 && code <= 3) return { desc: "Nuageux", icon: "bi-cloud", color: "text-light" };
|
||||||
@@ -41,8 +37,8 @@ function getWeatherDetails(code) {
|
|||||||
|
|
||||||
async function fetchRealWeather() {
|
async function fetchRealWeather() {
|
||||||
try {
|
try {
|
||||||
const lat = 48.8566; // Tu peux changer avec la latitude de ta ville
|
const lat = 48.8566;
|
||||||
const lon = 2.3522; // Tu peux changer avec la longitude de ta ville
|
const lon = 2.3522;
|
||||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code`;
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -55,31 +51,32 @@ async function fetchRealWeather() {
|
|||||||
apiTempEl.innerText = temp + "°C";
|
apiTempEl.innerText = temp + "°C";
|
||||||
apiDescEl.innerText = weather.desc;
|
apiDescEl.innerText = weather.desc;
|
||||||
apiIconEl.className = "bi " + weather.icon + " " + weather.color + " display-4 me-3";
|
apiIconEl.className = "bi " + weather.icon + " " + weather.color + " display-4 me-3";
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur Météo:", error);
|
console.error("Erreur Météo:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
function updateBattery(percent, alertTextFromApi = null) {
|
||||||
// --- MISE À JOUR VISUELLE DES WIDGETS ---
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
function updateBattery(percent) {
|
|
||||||
batValEl.innerText = percent + "%";
|
batValEl.innerText = percent + "%";
|
||||||
let color = "#34d399";
|
let color = "#34d399";
|
||||||
let shadowColor = "rgba(52, 211, 153, 0.4)";
|
let shadowColor = "rgba(52, 211, 153, 0.4)";
|
||||||
|
|
||||||
if (percent <= 20) {
|
if (alertTextFromApi && alertTextFromApi !== "none") {
|
||||||
color = "#ef4444";
|
color = "#ef4444";
|
||||||
shadowColor = "rgba(239, 68, 68, 0.6)";
|
shadowColor = "rgba(239, 68, 68, 0.6)";
|
||||||
batAlertText.innerText = "Tension Critique !";
|
batAlertText.innerText = alertTextFromApi;
|
||||||
|
batAlertIcon.className = "bi bi-exclamation-triangle-fill text-danger";
|
||||||
|
batAlertBadge.className = "badge bg-dark border border-danger text-secondary w-100 py-2";
|
||||||
|
} else if (percent <= 20) {
|
||||||
|
color = "#ef4444";
|
||||||
|
shadowColor = "rgba(239, 68, 68, 0.6)";
|
||||||
|
batAlertText.innerText = "Niveau critique";
|
||||||
batAlertIcon.className = "bi bi-exclamation-triangle-fill text-danger";
|
batAlertIcon.className = "bi bi-exclamation-triangle-fill text-danger";
|
||||||
batAlertBadge.className = "badge bg-dark border border-danger text-secondary w-100 py-2";
|
batAlertBadge.className = "badge bg-dark border border-danger text-secondary w-100 py-2";
|
||||||
} else if (percent <= 50) {
|
} else if (percent <= 50) {
|
||||||
color = "#fbbf24";
|
color = "#fbbf24";
|
||||||
shadowColor = "rgba(251, 191, 36, 0.5)";
|
shadowColor = "rgba(251, 191, 36, 0.5)";
|
||||||
batAlertText.innerText = "Niveau Moyen";
|
batAlertText.innerText = "Niveau moyen";
|
||||||
batAlertIcon.className = "bi bi-exclamation-circle text-warning";
|
batAlertIcon.className = "bi bi-exclamation-circle text-warning";
|
||||||
batAlertBadge.className = "badge bg-dark border border-warning text-secondary w-100 py-2";
|
batAlertBadge.className = "badge bg-dark border border-warning text-secondary w-100 py-2";
|
||||||
} else {
|
} else {
|
||||||
@@ -95,14 +92,12 @@ function updateBattery(percent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSolar(voltage, current, power, lux) {
|
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 v = Number(voltage).toFixed(1);
|
||||||
const c = Number(current).toFixed(2);
|
const c = Number(current).toFixed(2);
|
||||||
const p = Number(power).toFixed(1);
|
const p = Number(power).toFixed(1);
|
||||||
const l = Math.floor(Number(lux));
|
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) ? ((p / MAX_POWER) * 100).toFixed(1) : "0.0";
|
||||||
const efficiency = (p > 0) ? (14 + Math.random() * 2).toFixed(1) : "0.0";
|
|
||||||
|
|
||||||
document.getElementById('powerValue').textContent = p;
|
document.getElementById('powerValue').textContent = p;
|
||||||
document.getElementById('voltage').innerHTML = v + ' <span class="stat-unit">V</span>';
|
document.getElementById('voltage').innerHTML = v + ' <span class="stat-unit">V</span>';
|
||||||
@@ -110,7 +105,6 @@ function updateSolar(voltage, current, power, lux) {
|
|||||||
document.getElementById('lux').innerHTML = l.toLocaleString() + ' <span class="stat-unit">Lux</span>';
|
document.getElementById('lux').innerHTML = l.toLocaleString() + ' <span class="stat-unit">Lux</span>';
|
||||||
document.getElementById('efficiency').innerHTML = efficiency + ' <span class="stat-unit">%</span>';
|
document.getElementById('efficiency').innerHTML = efficiency + ' <span class="stat-unit">%</span>';
|
||||||
|
|
||||||
// Animation de la jauge SVG
|
|
||||||
const ratio = Math.min(p / MAX_POWER, 1);
|
const ratio = Math.min(p / MAX_POWER, 1);
|
||||||
const offset = halfCircumference - (ratio * halfCircumference);
|
const offset = halfCircumference - (ratio * halfCircumference);
|
||||||
|
|
||||||
@@ -119,15 +113,182 @@ function updateSolar(voltage, current, power, lux) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
function createEnvChart() {
|
||||||
// --- NOUVEAU : CONNEXION AU SERVEUR PYTHON (BASE DE DONNÉES) ---
|
const ctx = document.getElementById('envChart');
|
||||||
// =================================================================
|
if (!ctx) return;
|
||||||
|
|
||||||
|
envChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Température (°C)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#38bdf8',
|
||||||
|
backgroundColor: 'rgba(56, 189, 248, 0.2)',
|
||||||
|
pointBackgroundColor: '#38bdf8',
|
||||||
|
pointBorderColor: '#38bdf8',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Humidité (%)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff5c8a',
|
||||||
|
backgroundColor: 'rgba(255, 92, 138, 0.2)',
|
||||||
|
pointBackgroundColor: '#ff5c8a',
|
||||||
|
pointBorderColor: '#ff5c8a',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: '#e2e8f0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.1)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnergyChart() {
|
||||||
|
const ctx = document.getElementById('energyChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
energyChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Batterie (%)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#34d399',
|
||||||
|
backgroundColor: 'rgba(52, 211, 153, 0.2)',
|
||||||
|
pointBackgroundColor: '#34d399',
|
||||||
|
pointBorderColor: '#34d399',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Puissance solaire (W)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#facc15',
|
||||||
|
backgroundColor: 'rgba(250, 204, 21, 0.2)',
|
||||||
|
pointBackgroundColor: '#facc15',
|
||||||
|
pointBorderColor: '#facc15',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: '#e2e8f0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.1)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: '#94a3b8'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistoryData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history?limit=10');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Impossible de récupérer l'historique");
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await response.json();
|
||||||
|
|
||||||
|
const labels = history.map(item => {
|
||||||
|
const date = new Date(item.created_at);
|
||||||
|
return date.toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (envChart) {
|
||||||
|
envChart.data.labels = labels;
|
||||||
|
envChart.data.datasets[0].data = history.map(item => item.temperature_ext ?? null);
|
||||||
|
envChart.data.datasets[1].data = history.map(item => item.humidity_ext ?? null);
|
||||||
|
envChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (energyChart) {
|
||||||
|
energyChart.data.labels = labels;
|
||||||
|
energyChart.data.datasets[0].data = history.map(item => item.battery_level ?? null);
|
||||||
|
energyChart.data.datasets[1].data = history.map(item => item.power_pv ?? null);
|
||||||
|
energyChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur historique :", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDatabaseData() {
|
async function fetchDatabaseData() {
|
||||||
if (!isOnline) return;
|
if (!isOnline) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// On interroge l'URL magique de l'étudiant 4
|
|
||||||
const response = await fetch('/api/latest');
|
const response = await fetch('/api/latest');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -136,16 +297,13 @@ async function fetchDatabaseData() {
|
|||||||
|
|
||||||
const data = await response.json();
|
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) : "--";
|
tempEl.innerText = data.temperature_ext !== null ? Number(data.temperature_ext).toFixed(1) : "--";
|
||||||
humEl.innerText = data.humidity_ext !== null ? Math.floor(data.humidity_ext) : "--";
|
humEl.innerText = data.humidity_ext !== null ? Math.floor(data.humidity_ext) : "--";
|
||||||
|
|
||||||
// 2. Mise à jour de la Batterie
|
|
||||||
if (data.battery_level !== null) {
|
if (data.battery_level !== null) {
|
||||||
updateBattery(Math.floor(data.battery_level));
|
updateBattery(Math.floor(data.battery_level), data.battery_alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Mise à jour du Solaire
|
|
||||||
updateSolar(
|
updateSolar(
|
||||||
data.voltage_pv || 0,
|
data.voltage_pv || 0,
|
||||||
data.current_pv || 0,
|
data.current_pv || 0,
|
||||||
@@ -153,15 +311,12 @@ async function fetchDatabaseData() {
|
|||||||
data.luminosity || 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();
|
lastHeartbeat = Date.now();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("En attente de données capteurs...", error);
|
console.error("En attente de données capteurs...", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FONCTION WATCHDOG ---
|
|
||||||
function checkSystemStatus() {
|
function checkSystemStatus() {
|
||||||
if (Date.now() - lastHeartbeat > TIMEOUT_MS) {
|
if (Date.now() - lastHeartbeat > TIMEOUT_MS) {
|
||||||
statusDot.className = "status-dot rounded-circle pulse-offline";
|
statusDot.className = "status-dot rounded-circle pulse-offline";
|
||||||
@@ -174,21 +329,6 @@ function checkSystemStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// --- LANCEMENT DES BOUCLES ---
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
// Météo Web (1 fois au lancement, puis toutes les 15 min)
|
|
||||||
fetchRealWeather();
|
|
||||||
setInterval(fetchRealWeather, 900000);
|
|
||||||
|
|
||||||
// Base de données locale (toutes les 3 secondes)
|
|
||||||
setInterval(fetchDatabaseData, 3000);
|
|
||||||
|
|
||||||
// Watchdog (toutes les secondes)
|
|
||||||
setInterval(checkSystemStatus, 1000);
|
|
||||||
|
|
||||||
// Bouton de simulation de panne
|
|
||||||
document.getElementById('btn-test').addEventListener('click', function () {
|
document.getElementById('btn-test').addEventListener('click', function () {
|
||||||
isOnline = !isOnline;
|
isOnline = !isOnline;
|
||||||
if (isOnline) {
|
if (isOnline) {
|
||||||
@@ -200,3 +340,15 @@ document.getElementById('btn-test').addEventListener('click', function () {
|
|||||||
this.classList.replace('btn-outline-danger', 'btn-outline-success');
|
this.classList.replace('btn-outline-danger', 'btn-outline-success');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createEnvChart();
|
||||||
|
createEnergyChart();
|
||||||
|
fetchRealWeather();
|
||||||
|
fetchDatabaseData();
|
||||||
|
fetchHistoryData();
|
||||||
|
checkSystemStatus();
|
||||||
|
|
||||||
|
setInterval(fetchRealWeather, 900000);
|
||||||
|
setInterval(fetchDatabaseData, 3000);
|
||||||
|
setInterval(fetchHistoryData, 5000);
|
||||||
|
setInterval(checkSystemStatus, 1000);
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
<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="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -22,8 +21,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
class="d-flex align-items-center bg-dark bg-opacity-50 px-3 py-2 rounded-pill border border-secondary border-opacity-25">
|
class="d-flex align-items-center bg-dark bg-opacity-50 px-3 py-2 rounded-pill border border-secondary border-opacity-25">
|
||||||
<span id="status-text" class="me-3 fw-semibold small text-uppercase tracking-wide">Système en
|
<span id="status-text" class="me-3 fw-semibold small text-uppercase tracking-wide">Système en ligne</span>
|
||||||
ligne</span>
|
|
||||||
<div id="system-status" class="status-dot rounded-circle pulse-online"></div>
|
<div id="system-status" class="status-dot rounded-circle pulse-online"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,8 +44,7 @@
|
|||||||
<i id="api-icon" class="bi bi-brightness-high text-warning display-4 me-3"></i>
|
<i id="api-icon" class="bi bi-brightness-high text-warning display-4 me-3"></i>
|
||||||
<div class="text-start">
|
<div class="text-start">
|
||||||
<h3 class="mb-0 fw-bold data-value" id="api-temp">--°C</h3>
|
<h3 class="mb-0 fw-bold data-value" id="api-temp">--°C</h3>
|
||||||
<small class="text-secondary text-uppercase fw-bold" id="api-desc">En
|
<small class="text-secondary text-uppercase fw-bold" id="api-desc">En attente...</small>
|
||||||
attente...</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,11 +145,10 @@
|
|||||||
<div class="mt-4 text-center">
|
<div class="mt-4 text-center">
|
||||||
<div class="badge bg-dark border border-secondary text-secondary w-100 py-2"
|
<div class="badge bg-dark border border-secondary text-secondary w-100 py-2"
|
||||||
id="bat-alert-badge">
|
id="bat-alert-badge">
|
||||||
<i class="bi bi-check-circle text-success" id="bat-alert-icon"></i> <span
|
<i class="bi bi-check-circle text-success" id="bat-alert-icon"></i>
|
||||||
id="bat-alert">Système Normal</span>
|
<span id="bat-alert">Système Normal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +171,32 @@
|
|||||||
<i class="bi bi-box-arrow-right me-2"></i>Déconnexion
|
<i class="bi bi-box-arrow-right me-2"></i>Déconnexion
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card rounded-3 shadow-sm border border-secondary border-opacity-25 h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-bold mb-4 text-white">
|
||||||
|
<i class="bi bi-graph-up-arrow me-2 text-white"></i>Historique environnement
|
||||||
|
</h5>
|
||||||
|
<div style="height: 340px;">
|
||||||
|
<canvas id="envChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card rounded-3 shadow-sm border border-secondary border-opacity-25 h-100">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-bold mb-4 text-white">
|
||||||
|
<i class="bi bi-lightning-charge me-2 text-white"></i>Historique énergie
|
||||||
|
</h5>
|
||||||
|
<div style="height: 340px;">
|
||||||
|
<canvas id="energyChart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,6 +204,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user