commit
9aacc00631
31 changed files with 2493 additions and 0 deletions
@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
|
||||
# Spécifications générales |
||||
- Contexte: Application ESP32 |
||||
- Stack: Arduino C++, ESPAsyncWebServer, LittleFS |
||||
- Carte: ESP32 |
||||
- Interface: Dashboard web responsive |
||||
- Fonctionnalités clés: Gestion GPIO, OTA, WiFi/AP, Authentification, API REST |
||||
|
||||
# Arborescence des fichiers |
||||
``` |
||||
src/ |
||||
├── main.cpp [Point d'entrée] |
||||
├── auth.h/cpp [Authentification] |
||||
├── server.h/cpp [API endpoints] |
||||
└── utils.h/cpp [Helpers] |
||||
|
||||
data/ |
||||
├── config.json [Config par défaut] |
||||
├── index.html [Dashboard] |
||||
├── login.html [Page de login] |
||||
├── settings.html [Paramètres] |
||||
├── update.html [Mise à jour OTA] |
||||
└── js/ |
||||
├── api.js [Client API] |
||||
└── app.js [UI logic] |
||||
└── css/ |
||||
└── styles.css [Styles CSS] |
||||
``` |
||||
|
||||
# Spécificités ESP32 |
||||
- Framework: Arduino |
||||
- Système de fichiers: LittleFS |
||||
- Utilisation de platformio pour la gestion des dépendances et le build |
||||
``` |
||||
[env:esp32] |
||||
platform = espressif32 |
||||
board = esp32dev |
||||
framework = arduino |
||||
monitor_speed = 115200 |
||||
board_build.filesystem = littlefs |
||||
board_build.partitions = default.csv |
||||
|
||||
lib_deps = |
||||
ESP32Async/ESPAsyncWebServer |
||||
ESP32Async/AsyncTCP |
||||
bblanchon/ArduinoJson@^7.0.0 |
||||
|
||||
build_flags = |
||||
-DBOARD_HAS_PSRAM |
||||
``` |
||||
|
||||
# Spécificités dashboard web |
||||
- Pages: Login, Dashboard, Paramètres, Mise à jour OTA |
||||
- Technologies: HTML5, CSS3, JavaScript (Fetch API) |
||||
- Sécurité: Sessions avec cookies, redirection vers login si non authentifié |
||||
- Gestion des erreurs: Messages utilisateur clairs et en français |
||||
- Responsive: Adapté aux mobiles et tablettes |
||||
|
||||
# Spécificités stockage fichiers |
||||
- Système de fichiers: LittleFS |
||||
- Fichiers: HTML, CSS, JS, config.json |
||||
- Accès: Lecture seule pour fichiers web, lecture/écriture pour config.json |
||||
- Création d'une config.json par défaut si absente au démarrage |
||||
- Pas de fichiers compressés pour compatibilité avec LittleFS |
||||
|
||||
# Fonctionnalités OTA |
||||
- Mise à jour via interface web ou via espota |
||||
- Mise à jour firmware et système de fichiers depuis interface web |
||||
|
||||
# Spécificités API REST |
||||
- Endpoints: /api/config, /api/stats |
||||
- Sécurité: Authentification requise pour endpoints protégés |
||||
- Réponses: Statuts HTTP et messages d'erreur clairs |
||||
|
||||
# Spécificités Authentification |
||||
- Stockage: Nom d'utilisateur et hash SHA-256 du mot de passe dans config.json |
||||
- Sécurité: Sessions avec timeout configurable |
||||
- Gestion des sesssions avec cookies |
||||
- Redirection vers page de login si non authentifié |
||||
- Appel GET vers /logout pour déconnexion et /login pour connexion |
||||
|
||||
# Spécificités wifi |
||||
- Modes: Station (client) et Point d'accès (AP) si absence de réseau et/ou paramètres non configurés |
||||
- Sécurité: WPA2 pour Station et pour AP |
||||
- Configuration: Via interface web ou fichier config.json |
||||
|
||||
# Spécificités logs |
||||
- Sortie série pour debug en français |
||||
- Niveau de log configurable (DEBUG, INFO, WARN, ERROR) |
||||
|
||||
# Format de configuration (config.json) |
||||
```json |
||||
{ |
||||
"auth": { |
||||
"username": "admin", |
||||
"password_hash": "SHA-256" |
||||
}, |
||||
"wifi": { |
||||
"ssid": "Réseau WiFi", |
||||
"password": "***", |
||||
"ap_password": "Mode AP pwd" |
||||
}, |
||||
"system": { |
||||
"hostname": "WEBAPP", |
||||
"session_timeout": 60, // minutes |
||||
}, |
||||
"params": { |
||||
} |
||||
} |
||||
``` |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
--- |
||||
agent: agent |
||||
--- |
||||
|
||||
# Met à jour l'application nommée "ESP32 Sonnerie" pour qu'elle s'appelle comme indiqué dans le prompt dans tous les fichiers du projet. |
||||
- Les commentaires doivent aussi être mis à jour pour refléter le nouveau nom de l'application. |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
--- |
||||
agent: agent |
||||
--- |
||||
|
||||
# Mise à jour des fichiers md |
||||
Mets à jour les fichiers markdown suivants pour refléter les modifications récentes apportées au code et aux fichiers de l'application |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
# PlatformIO |
||||
.pio |
||||
.pioenvs |
||||
.piolibdeps |
||||
|
||||
# Git |
||||
.git |
||||
|
||||
# Configuration locale (peut contenir des mots de passe) |
||||
data/config.json |
||||
|
||||
# VS Code |
||||
.vscode/ |
||||
|
||||
# Backups |
||||
*.bak |
||||
*~ |
||||
|
||||
# OS |
||||
.DS_Store |
||||
Thumbs.db |
||||
|
||||
# Build artifacts |
||||
*.bin |
||||
*.elf |
||||
*.map |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3 |
||||
import os |
||||
import gzip |
||||
import shutil |
||||
from pathlib import Path |
||||
|
||||
def compress_file(src_path, dst_path): |
||||
"""Compress a file using gzip.""" |
||||
try: |
||||
with open(src_path, 'rb') as f_in: |
||||
with gzip.open(dst_path, 'wb', compresslevel=9) as f_out: |
||||
shutil.copyfileobj(f_in, f_out) |
||||
print(f"✓ Compressed: {src_path} -> {dst_path}") |
||||
return True |
||||
except Exception as e: |
||||
print(f"✗ Error compressing {src_path}: {e}") |
||||
return False |
||||
|
||||
def compress_data_directory(data_dir): |
||||
"""Compress CSS, JS, and HTML files in data directory.""" |
||||
data_path = Path(data_dir) |
||||
|
||||
if not data_path.exists(): |
||||
print(f"Data directory not found: {data_dir}") |
||||
return |
||||
|
||||
files_to_compress = [] |
||||
|
||||
# Find all CSS files |
||||
files_to_compress.extend(data_path.glob("**/*.css")) |
||||
|
||||
# Find all JS files |
||||
files_to_compress.extend(data_path.glob("**/*.js")) |
||||
|
||||
# Find all HTML files (excluding .gz files) |
||||
files_to_compress.extend(data_path.glob("**/*.html")) |
||||
|
||||
# Filter out files that are already .gz |
||||
files_to_compress = [f for f in files_to_compress if not str(f).endswith('.gz')] |
||||
|
||||
if not files_to_compress: |
||||
print("No files to compress found.") |
||||
return |
||||
|
||||
print(f"Compressing {len(files_to_compress)} files...") |
||||
|
||||
compressed_count = 0 |
||||
for src_file in files_to_compress: |
||||
dst_file = Path(str(src_file) + ".gz") |
||||
if compress_file(src_file, dst_file): |
||||
compressed_count += 1 |
||||
|
||||
print(f"\n✓ Successfully compressed {compressed_count}/{len(files_to_compress)} files") |
||||
|
||||
if __name__ == "__main__": |
||||
import sys |
||||
data_dir = sys.argv[1] if len(sys.argv) > 1 else "data" |
||||
compress_data_directory(data_dir) |
||||
@ -0,0 +1,327 @@
@@ -0,0 +1,327 @@
|
||||
/* Variables CSS */ |
||||
:root { |
||||
--primary-color: #007bff; |
||||
--primary-dark: #0056b3; |
||||
--secondary-color: #6c757d; |
||||
--success-color: #28a745; |
||||
--danger-color: #dc3545; |
||||
--warning-color: #ffc107; |
||||
--info-color: #17a2b8; |
||||
--light-color: #f8f9fa; |
||||
--dark-color: #343a40; |
||||
--white: #ffffff; |
||||
--border-radius: 8px; |
||||
--box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||
--transition: all 0.3s ease; |
||||
} |
||||
|
||||
/* Reset et base */ |
||||
* { |
||||
margin: 0; |
||||
padding: 0; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
body { |
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; |
||||
font-size: 16px; |
||||
line-height: 1.6; |
||||
color: var(--dark-color); |
||||
background-color: var(--light-color); |
||||
} |
||||
|
||||
/* Navigation */ |
||||
.navbar { |
||||
background-color: var(--primary-color); |
||||
color: var(--white); |
||||
padding: 1rem 2rem; |
||||
box-shadow: var(--box-shadow); |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.nav-brand { |
||||
font-size: 1.5rem; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.nav-menu { |
||||
display: flex; |
||||
gap: 1rem; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.nav-menu a { |
||||
color: var(--white); |
||||
text-decoration: none; |
||||
padding: 0.5rem 1rem; |
||||
border-radius: var(--border-radius); |
||||
transition: var(--transition); |
||||
} |
||||
|
||||
.nav-menu a:hover, |
||||
.nav-menu a.active { |
||||
background-color: var(--primary-dark); |
||||
} |
||||
|
||||
/* Container */ |
||||
.container { |
||||
max-width: 1200px; |
||||
margin: 2rem auto; |
||||
padding: 0 1rem; |
||||
} |
||||
|
||||
/* Cards */ |
||||
.card { |
||||
background-color: var(--white); |
||||
border-radius: var(--border-radius); |
||||
box-shadow: var(--box-shadow); |
||||
padding: 1.5rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.card h2 { |
||||
margin-bottom: 1rem; |
||||
color: var(--primary-color); |
||||
} |
||||
|
||||
.card.warning { |
||||
border-left: 4px solid var(--warning-color); |
||||
background-color: #fff3cd; |
||||
} |
||||
|
||||
.card.warning h3 { |
||||
color: var(--warning-color); |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.card.warning ul { |
||||
margin-left: 1.5rem; |
||||
} |
||||
|
||||
/* Grille de statistiques */ |
||||
.stats-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
||||
gap: 1rem; |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.stat-card { |
||||
background-color: var(--white); |
||||
border-radius: var(--border-radius); |
||||
box-shadow: var(--box-shadow); |
||||
padding: 1.5rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.stat-label { |
||||
font-size: 0.9rem; |
||||
color: var(--secondary-color); |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.stat-value { |
||||
font-size: 1.5rem; |
||||
font-weight: bold; |
||||
color: var(--primary-color); |
||||
} |
||||
|
||||
/* Grille de contrôles */ |
||||
.controls-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); |
||||
gap: 1rem; |
||||
} |
||||
|
||||
/* Formulaires */ |
||||
.form-group { |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.form-group label { |
||||
display: block; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 500; |
||||
color: var(--dark-color); |
||||
} |
||||
|
||||
.form-group input[type="text"], |
||||
.form-group input[type="password"], |
||||
.form-group input[type="number"], |
||||
.form-group input[type="file"] { |
||||
width: 100%; |
||||
padding: 0.75rem; |
||||
border: 1px solid #ddd; |
||||
border-radius: var(--border-radius); |
||||
font-size: 1rem; |
||||
transition: var(--transition); |
||||
} |
||||
|
||||
.form-group input:focus { |
||||
outline: none; |
||||
border-color: var(--primary-color); |
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); |
||||
} |
||||
|
||||
/* Boutons */ |
||||
.btn { |
||||
display: inline-block; |
||||
padding: 0.75rem 1.5rem; |
||||
font-size: 1rem; |
||||
font-weight: 500; |
||||
text-align: center; |
||||
text-decoration: none; |
||||
border: none; |
||||
border-radius: var(--border-radius); |
||||
cursor: pointer; |
||||
transition: var(--transition); |
||||
width: 100%; |
||||
} |
||||
|
||||
.btn-primary { |
||||
background-color: var(--primary-color); |
||||
color: var(--white); |
||||
} |
||||
|
||||
.btn-primary:hover { |
||||
background-color: var(--primary-dark); |
||||
} |
||||
|
||||
.btn-secondary { |
||||
background-color: var(--secondary-color); |
||||
color: var(--white); |
||||
} |
||||
|
||||
.btn-secondary:hover { |
||||
background-color: #5a6268; |
||||
} |
||||
|
||||
/* Messages */ |
||||
.message { |
||||
padding: 1rem; |
||||
border-radius: var(--border-radius); |
||||
margin-top: 1rem; |
||||
margin-bottom: 1rem; |
||||
display: none; |
||||
} |
||||
|
||||
.message.success { |
||||
display: block; |
||||
background-color: #d4edda; |
||||
border: 1px solid #c3e6cb; |
||||
color: #155724; |
||||
} |
||||
|
||||
.message.error { |
||||
display: block; |
||||
background-color: #f8d7da; |
||||
border: 1px solid #f5c6cb; |
||||
color: #721c24; |
||||
} |
||||
|
||||
.message.info { |
||||
display: block; |
||||
background-color: #d1ecf1; |
||||
border: 1px solid #bee5eb; |
||||
color: #0c5460; |
||||
} |
||||
|
||||
/* Barre de progression */ |
||||
.progress-container { |
||||
margin-top: 1rem; |
||||
} |
||||
|
||||
.progress-bar { |
||||
width: 100%; |
||||
height: 30px; |
||||
background-color: #e9ecef; |
||||
border-radius: var(--border-radius); |
||||
overflow: hidden; |
||||
} |
||||
|
||||
.progress-fill { |
||||
height: 100%; |
||||
background-color: var(--primary-color); |
||||
transition: width 0.3s ease; |
||||
width: 0%; |
||||
} |
||||
|
||||
.progress-text { |
||||
text-align: center; |
||||
margin-top: 0.5rem; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
/* Page de login */ |
||||
.login-page { |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
min-height: 100vh; |
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark)); |
||||
} |
||||
|
||||
.login-container { |
||||
width: 100%; |
||||
max-width: 400px; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.login-card { |
||||
background-color: var(--white); |
||||
border-radius: var(--border-radius); |
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); |
||||
padding: 2rem; |
||||
} |
||||
|
||||
.login-card h1 { |
||||
text-align: center; |
||||
color: var(--primary-color); |
||||
margin-bottom: 0.5rem; |
||||
font-size: 2rem; |
||||
} |
||||
|
||||
.login-card h2 { |
||||
text-align: center; |
||||
color: var(--secondary-color); |
||||
margin-bottom: 2rem; |
||||
font-size: 1.5rem; |
||||
} |
||||
|
||||
/* Responsive */ |
||||
@media (max-width: 768px) { |
||||
.navbar { |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.nav-menu { |
||||
justify-content: center; |
||||
} |
||||
|
||||
.stats-grid { |
||||
grid-template-columns: 1fr; |
||||
} |
||||
|
||||
.controls-grid { |
||||
grid-template-columns: 1fr; |
||||
} |
||||
} |
||||
|
||||
@media (max-width: 480px) { |
||||
.container { |
||||
margin: 1rem auto; |
||||
} |
||||
|
||||
.card { |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.nav-menu a { |
||||
padding: 0.5rem; |
||||
font-size: 0.9rem; |
||||
} |
||||
} |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
// Client API pour interagir avec le backend
|
||||
|
||||
const api = { |
||||
// Vérifier l'authentification
|
||||
async checkAuth() { |
||||
try { |
||||
const response = await fetch('/api/stats'); |
||||
if (response.status === 401) { |
||||
window.location.href = '/login.html'; |
||||
return false; |
||||
} |
||||
return response.ok; |
||||
} catch (error) { |
||||
console.error('Erreur de vérification d\'authentification:', error); |
||||
return false; |
||||
} |
||||
}, |
||||
|
||||
// Récupérer la configuration
|
||||
async getConfig() { |
||||
const response = await fetch('/api/config'); |
||||
if (response.status === 401) { |
||||
window.location.href = '/login.html'; |
||||
throw new Error('Non authentifié'); |
||||
} |
||||
if (!response.ok) { |
||||
throw new Error('Erreur de récupération de la configuration'); |
||||
} |
||||
return await response.json(); |
||||
}, |
||||
|
||||
// Enregistrer la configuration
|
||||
async setConfig(config) { |
||||
const response = await fetch('/api/config', { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/x-www-form-urlencoded' |
||||
}, |
||||
body: 'config=' + encodeURIComponent(JSON.stringify(config)) |
||||
}); |
||||
|
||||
if (response.status === 401) { |
||||
window.location.href = '/login.html'; |
||||
throw new Error('Non authentifié'); |
||||
} |
||||
|
||||
if (!response.ok) { |
||||
throw new Error('Erreur d\'enregistrement de la configuration'); |
||||
} |
||||
|
||||
return await response.json(); |
||||
}, |
||||
|
||||
// Changer le mot de passe
|
||||
async changePassword(config) { |
||||
const response = await fetch('/api/password', { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/x-www-form-urlencoded' |
||||
}, |
||||
body: 'config=' + encodeURIComponent(JSON.stringify(config)) |
||||
}); |
||||
|
||||
if (response.status === 401) { |
||||
throw new Error('Non authentifié ou mot de passe incorrect'); |
||||
} |
||||
|
||||
if (!response.ok) { |
||||
const error = await response.json(); |
||||
throw new Error(error.message || 'Erreur de changement de mot de passe'); |
||||
} |
||||
|
||||
return await response.json(); |
||||
}, |
||||
|
||||
// Récupérer les statistiques
|
||||
async getStats() { |
||||
const response = await fetch('/api/stats'); |
||||
if (response.status === 401) { |
||||
window.location.href = '/login.html'; |
||||
throw new Error('Non authentifié'); |
||||
} |
||||
if (!response.ok) { |
||||
throw new Error('Erreur de récupération des statistiques'); |
||||
} |
||||
return await response.json(); |
||||
}, |
||||
|
||||
// Déconnexion
|
||||
async logout() { |
||||
const response = await fetch('/logout'); |
||||
if (response.ok) { |
||||
window.location.href = '/login.html'; |
||||
} |
||||
} |
||||
}; |
||||
|
||||
// Vérifier l'authentification au chargement de la page (sauf sur login.html)
|
||||
if (!window.location.pathname.includes('login.html')) { |
||||
api.checkAuth(); |
||||
} |
||||
Binary file not shown.
@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
|
||||
// Application principale pour l'application web ESP32
|
||||
|
||||
// Formater le temps de fonctionnement
|
||||
function formatUptime(seconds) { |
||||
const days = Math.floor(seconds / 86400); |
||||
const hours = Math.floor((seconds % 86400) / 3600); |
||||
const minutes = Math.floor((seconds % 3600) / 60); |
||||
const secs = seconds % 60; |
||||
|
||||
let result = ''; |
||||
if (days > 0) result += `${days}j `; |
||||
if (hours > 0) result += `${hours}h `; |
||||
if (minutes > 0) result += `${minutes}m `; |
||||
result += `${secs}s`; |
||||
|
||||
return result; |
||||
} |
||||
|
||||
// Formater la mémoire
|
||||
function formatMemory(bytes) { |
||||
if (bytes < 1024) return bytes + ' B'; |
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; |
||||
} |
||||
|
||||
// Formater le signal WiFi
|
||||
function formatRSSI(rssi) { |
||||
if (rssi >= -50) return `${rssi} dBm (Excellent)`; |
||||
if (rssi >= -60) return `${rssi} dBm (Bon)`; |
||||
if (rssi >= -70) return `${rssi} dBm (Moyen)`; |
||||
if (rssi >= -80) return `${rssi} dBm (Faible)`; |
||||
return `${rssi} dBm (Très faible)`; |
||||
} |
||||
|
||||
// Rafraîchir les statistiques
|
||||
async function refreshStats() { |
||||
try { |
||||
const stats = await api.getStats(); |
||||
|
||||
// Mettre à jour l'affichage
|
||||
document.getElementById('uptime').textContent = formatUptime(stats.uptime); |
||||
document.getElementById('memory').textContent = formatMemory(stats.heap_free) + ' / ' + formatMemory(stats.heap_total); |
||||
document.getElementById('rssi').textContent = formatRSSI(stats.wifi_rssi); |
||||
document.getElementById('ip').textContent = stats.ip; |
||||
|
||||
} catch (error) { |
||||
console.error('Erreur de récupération des statistiques:', error); |
||||
showMessage('Erreur de récupération des statistiques', 'error'); |
||||
} |
||||
} |
||||
|
||||
// Afficher un message
|
||||
function showMessage(text, type = 'info') { |
||||
const messageDiv = document.getElementById('message'); |
||||
if (messageDiv) { |
||||
messageDiv.className = `message ${type}`; |
||||
messageDiv.textContent = text; |
||||
|
||||
setTimeout(() => { |
||||
messageDiv.className = 'message'; |
||||
messageDiv.textContent = ''; |
||||
}, 5000); |
||||
} |
||||
} |
||||
|
||||
// Initialisation au chargement de la page
|
||||
document.addEventListener('DOMContentLoaded', () => { |
||||
// Rafraîchir les stats si on est sur la page d'accueil
|
||||
if (window.location.pathname.includes('index.html') || window.location.pathname === '/') { |
||||
refreshStats(); |
||||
|
||||
// Rafraîchir automatiquement toutes les 5 secondes
|
||||
setInterval(refreshStats, 5000); |
||||
} |
||||
}); |
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="fr"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Connexion - ESP32 Webapp</title> |
||||
<link rel="stylesheet" href="/css/styles.css"> |
||||
</head> |
||||
<body class="login-page"> |
||||
<div class="login-container"> |
||||
<div class="login-card"> |
||||
<h1>🔔 ESP32 Webapp</h1> |
||||
<h2>Connexion</h2> |
||||
|
||||
<form id="loginForm"> |
||||
<div class="form-group"> |
||||
<label for="username">Nom d'utilisateur</label> |
||||
<input type="text" id="username" name="username" required autocomplete="username"> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="password">Mot de passe</label> |
||||
<input type="password" id="password" name="password" required autocomplete="current-password"> |
||||
</div> |
||||
|
||||
<button type="submit" class="btn btn-primary">Se connecter</button> |
||||
|
||||
<div id="message" class="message"></div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
|
||||
<script src="/js/api.js"></script> |
||||
<script> |
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => { |
||||
e.preventDefault(); |
||||
|
||||
const username = document.getElementById('username').value; |
||||
const password = document.getElementById('password').value; |
||||
const messageDiv = document.getElementById('message'); |
||||
|
||||
try { |
||||
const response = await fetch('/login', { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/x-www-form-urlencoded' |
||||
}, |
||||
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}` |
||||
}); |
||||
|
||||
const data = await response.json(); |
||||
|
||||
if (data.success) { |
||||
messageDiv.className = 'message success'; |
||||
messageDiv.textContent = data.message; |
||||
setTimeout(() => { |
||||
window.location.href = '/index.html'; |
||||
}, 500); |
||||
} else { |
||||
messageDiv.className = 'message error'; |
||||
messageDiv.textContent = data.message; |
||||
} |
||||
} catch (error) { |
||||
messageDiv.className = 'message error'; |
||||
messageDiv.textContent = 'Erreur de connexion'; |
||||
} |
||||
}); |
||||
</script> |
||||
</body> |
||||
</html> |
||||
Binary file not shown.
@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html> |
||||
<html lang="fr"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Paramètres - ESP32 Webapp</title> |
||||
<link rel="stylesheet" href="/css/styles.css"> |
||||
</head> |
||||
<body> |
||||
<nav class="navbar"> |
||||
<div class="nav-brand">🔔 ESP32 Webapp</div> |
||||
<div class="nav-menu"> |
||||
<a href="/index.html">Tableau de bord</a> |
||||
<a href="/settings.html" class="active">Paramètres</a> |
||||
<a href="/update.html">Mise à jour</a> |
||||
<a href="/logout">Déconnexion</a> |
||||
</div> |
||||
</nav> |
||||
|
||||
<div class="container"> |
||||
<h1>Paramètres</h1> |
||||
|
||||
<div class="card"> |
||||
<h2>Configuration WiFi</h2> |
||||
<form id="wifiForm"> |
||||
<div class="form-group"> |
||||
<label for="wifi_ssid">SSID du réseau</label> |
||||
<input type="text" id="wifi_ssid" name="wifi_ssid"> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="wifi_password">Mot de passe WiFi</label> |
||||
<input type="password" id="wifi_password" name="wifi_password" placeholder="Laisser vide pour ne pas changer"> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="ap_password">Mot de passe du Point d'Accès</label> |
||||
<input type="password" id="ap_password" name="ap_password" placeholder="Laisser vide pour ne pas changer"> |
||||
</div> |
||||
|
||||
<button type="submit" class="btn btn-primary">Enregistrer WiFi</button> |
||||
</form> |
||||
</div> |
||||
|
||||
<div class="card"> |
||||
<h2>Configuration système</h2> |
||||
<form id="systemForm"> |
||||
<div class="form-group"> |
||||
<label for="hostname">Nom d'hôte</label> |
||||
<input type="text" id="hostname" name="hostname"> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="session_timeout">Timeout de session (minutes)</label> |
||||
<input type="number" id="session_timeout" name="session_timeout" min="1" max="1440"> |
||||
</div> |
||||
|
||||
<button type="submit" class="btn btn-primary">Enregistrer système</button> |
||||
</form> |
||||
</div> |
||||
|
||||
<div class="card"> |
||||
<h2>Changer le mot de passe</h2> |
||||
<form id="passwordForm"> |
||||
<div class="form-group"> |
||||
<label for="current_password">Mot de passe actuel</label> |
||||
<input type="password" id="current_password" name="current_password" required> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="new_password">Nouveau mot de passe</label> |
||||
<input type="password" id="new_password" name="new_password" required> |
||||
</div> |
||||
|
||||
<div class="form-group"> |
||||
<label for="confirm_password">Confirmer le mot de passe</label> |
||||
<input type="password" id="confirm_password" name="confirm_password" required> |
||||
</div> |
||||
|
||||
<button type="submit" class="btn btn-primary">Changer le mot de passe</button> |
||||
</form> |
||||
</div> |
||||
|
||||
<div id="message" class="message"></div> |
||||
</div> |
||||
|
||||
<script src="/js/api.js"></script> |
||||
<script src="/js/crypto-js.min.js"></script> |
||||
<script> |
||||
let config = {}; |
||||
|
||||
async function loadConfig() { |
||||
try { |
||||
config = await api.getConfig(); |
||||
|
||||
// WiFi |
||||
document.getElementById('wifi_ssid').value = config.wifi.ssid || ''; |
||||
|
||||
// Système |
||||
document.getElementById('hostname').value = config.system.hostname || ''; |
||||
document.getElementById('session_timeout').value = config.system.session_timeout || 60; |
||||
} catch (error) { |
||||
showMessage('Erreur de chargement de la configuration', 'error'); |
||||
} |
||||
} |
||||
|
||||
function showMessage(text, type = 'info') { |
||||
const messageDiv = document.getElementById('message'); |
||||
messageDiv.className = `message ${type}`; |
||||
messageDiv.textContent = text; |
||||
setTimeout(() => { |
||||
messageDiv.className = 'message'; |
||||
messageDiv.textContent = ''; |
||||
}, 5000); |
||||
} |
||||
|
||||
document.getElementById('wifiForm').addEventListener('submit', async (e) => { |
||||
e.preventDefault(); |
||||
|
||||
config.wifi.ssid = document.getElementById('wifi_ssid').value; |
||||
|
||||
const wifiPassword = document.getElementById('wifi_password').value; |
||||
if (wifiPassword) { |
||||
config.wifi.password = wifiPassword; |
||||
} |
||||
|
||||
const apPassword = document.getElementById('ap_password').value; |
||||
if (apPassword) { |
||||
config.wifi.ap_password = apPassword; |
||||
} |
||||
|
||||
try { |
||||
await api.setConfig(config); |
||||
showMessage('Configuration WiFi enregistrée. Redémarrage...', 'success'); |
||||
} catch (error) { |
||||
showMessage('Erreur d\'enregistrement', 'error'); |
||||
} |
||||
}); |
||||
|
||||
document.getElementById('systemForm').addEventListener('submit', async (e) => { |
||||
e.preventDefault(); |
||||
|
||||
config.system.hostname = document.getElementById('hostname').value; |
||||
config.system.session_timeout = parseInt(document.getElementById('session_timeout').value); |
||||
|
||||
try { |
||||
await api.setConfig(config); |
||||
showMessage('Configuration système enregistrée. Redémarrage...', 'success'); |
||||
} catch (error) { |
||||
showMessage('Erreur d\'enregistrement', 'error'); |
||||
} |
||||
}); |
||||
|
||||
document.getElementById('passwordForm').addEventListener('submit', async (e) => { |
||||
e.preventDefault(); |
||||
|
||||
const currentPassword = document.getElementById('current_password').value; |
||||
const newPassword = document.getElementById('new_password').value; |
||||
const confirmPassword = document.getElementById('confirm_password').value; |
||||
|
||||
if (newPassword !== confirmPassword) { |
||||
showMessage('Les mots de passe ne correspondent pas', 'error'); |
||||
return; |
||||
} |
||||
|
||||
if (newPassword.length < 4) { |
||||
showMessage('Le mot de passe doit contenir au moins 4 caractères', 'error'); |
||||
return; |
||||
} |
||||
|
||||
// Calculer le hash SHA-256 du nouveau mot de passe avec CryptoJS |
||||
const hashHex = CryptoJS.SHA256(newPassword).toString(); |
||||
|
||||
const passwordConfig = { |
||||
auth: { |
||||
old_password: currentPassword, |
||||
password_hash: hashHex |
||||
} |
||||
}; |
||||
|
||||
try { |
||||
await api.changePassword(passwordConfig); |
||||
showMessage('Mot de passe changé avec succès. Redémarrage...', 'success'); |
||||
setTimeout(() => { |
||||
window.location.href = '/logout'; |
||||
}, 2000); |
||||
} catch (error) { |
||||
if (error.message.includes('401')) { |
||||
showMessage('Mot de passe actuel incorrect', 'error'); |
||||
} else { |
||||
showMessage('Erreur de changement de mot de passe: ' + error.message, 'error'); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
loadConfig(); |
||||
</script> |
||||
</body> |
||||
</html> |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
[env:esp32] |
||||
platform = espressif32 |
||||
board = esp32dev |
||||
framework = arduino |
||||
monitor_speed = 115200 |
||||
board_build.filesystem = littlefs |
||||
board_build.partitions = default.csv |
||||
extra_scripts = platformio_extra.py |
||||
|
||||
lib_deps = |
||||
ESP32Async/ESPAsyncWebServer |
||||
ESP32Async/AsyncTCP |
||||
bblanchon/ArduinoJson@^7.0.0 |
||||
|
||||
build_flags = |
||||
-DBOARD_HAS_PSRAM |
||||
|
||||
upload_speed = 921600 |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3 |
||||
""" |
||||
PlatformIO extra script to compress assets before building filesystem |
||||
""" |
||||
import subprocess |
||||
import sys |
||||
from pathlib import Path |
||||
|
||||
def before_buildfs(source, target, env): |
||||
"""Hook called before building filesystem image.""" |
||||
project_dir = Path(env.get("PROJECT_DIR")) |
||||
data_dir = project_dir / "data" |
||||
|
||||
# Get the script path |
||||
script_path = project_dir / "compress_assets.py" |
||||
|
||||
if not script_path.exists(): |
||||
print(f"Warning: {script_path} not found") |
||||
return |
||||
|
||||
print("\n" + "="*60) |
||||
print("Compressing web assets (CSS, JS, HTML)...") |
||||
print("="*60 + "\n") |
||||
|
||||
try: |
||||
result = subprocess.run( |
||||
[sys.executable, str(script_path), str(data_dir)], |
||||
check=True, |
||||
cwd=str(project_dir) |
||||
) |
||||
except subprocess.CalledProcessError as e: |
||||
print(f"Error compressing assets: {e}") |
||||
sys.exit(1) |
||||
|
||||
# Register the hook |
||||
Import("env") |
||||
env.AddPreAction("buildfs", before_buildfs) |
||||
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
#include "auth.h" |
||||
#include "utils.h" |
||||
|
||||
AuthManager authManager; |
||||
|
||||
AuthManager::AuthManager() { |
||||
sessionTimeout = 60; // 60 minutes par défaut
|
||||
} |
||||
|
||||
void AuthManager::setCredentials(const String& user, const String& passHash) { |
||||
username = user; |
||||
passwordHash = passHash; |
||||
logMessage(LOG_INFO, "Identifiants configurés pour l'utilisateur: " + user); |
||||
} |
||||
|
||||
void AuthManager::setSessionTimeout(unsigned long timeout) { |
||||
sessionTimeout = timeout; |
||||
logMessage(LOG_INFO, "Timeout de session défini à " + String(timeout) + " minutes"); |
||||
} |
||||
|
||||
bool AuthManager::authenticate(const String& user, const String& password) { |
||||
String inputHash = sha256(password); |
||||
|
||||
if (user == username && inputHash == passwordHash) { |
||||
logMessage(LOG_INFO, "Authentification réussie pour: " + user); |
||||
return true; |
||||
} |
||||
|
||||
logMessage(LOG_WARN, "Échec d'authentification pour: " + user); |
||||
return false; |
||||
} |
||||
|
||||
String AuthManager::createSession(const String& user) { |
||||
String sessionId = generateSessionId(); |
||||
Session session; |
||||
session.username = user; |
||||
session.lastActivity = getCurrentTime(); |
||||
session.timeout = sessionTimeout * 60 * 1000; // conversion en millisecondes
|
||||
|
||||
sessions[sessionId] = session; |
||||
logMessage(LOG_INFO, "Session créée: " + sessionId + " pour " + user); |
||||
|
||||
return sessionId; |
||||
} |
||||
|
||||
bool AuthManager::validateSession(const String& sessionId) { |
||||
auto it = sessions.find(sessionId); |
||||
if (it == sessions.end()) { |
||||
return false; |
||||
} |
||||
|
||||
unsigned long now = getCurrentTime(); |
||||
unsigned long elapsed = now - it->second.lastActivity; |
||||
|
||||
if (elapsed > it->second.timeout) { |
||||
logMessage(LOG_INFO, "Session expirée: " + sessionId); |
||||
sessions.erase(it); |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
void AuthManager::updateSessionActivity(const String& sessionId) { |
||||
auto it = sessions.find(sessionId); |
||||
if (it != sessions.end()) { |
||||
it->second.lastActivity = getCurrentTime(); |
||||
} |
||||
} |
||||
|
||||
void AuthManager::removeSession(const String& sessionId) { |
||||
sessions.erase(sessionId); |
||||
logMessage(LOG_INFO, "Session supprimée: " + sessionId); |
||||
} |
||||
|
||||
void AuthManager::cleanExpiredSessions() { |
||||
unsigned long now = getCurrentTime(); |
||||
auto it = sessions.begin(); |
||||
|
||||
while (it != sessions.end()) { |
||||
unsigned long elapsed = now - it->second.lastActivity; |
||||
if (elapsed > it->second.timeout) { |
||||
logMessage(LOG_INFO, "Nettoyage session expirée: " + it->first); |
||||
it = sessions.erase(it); |
||||
} else { |
||||
++it; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
#ifndef AUTH_H |
||||
#define AUTH_H |
||||
|
||||
#include <Arduino.h> |
||||
#include <map> |
||||
|
||||
struct Session { |
||||
String username; |
||||
unsigned long lastActivity; |
||||
unsigned long timeout; // en millisecondes
|
||||
}; |
||||
|
||||
class AuthManager { |
||||
private: |
||||
String username; |
||||
String passwordHash; |
||||
std::map<String, Session> sessions; |
||||
unsigned long sessionTimeout; // en minutes
|
||||
|
||||
public: |
||||
AuthManager(); |
||||
void setCredentials(const String& user, const String& passHash); |
||||
void setSessionTimeout(unsigned long timeout); |
||||
|
||||
bool authenticate(const String& user, const String& password); |
||||
String createSession(const String& user); |
||||
bool validateSession(const String& sessionId); |
||||
void updateSessionActivity(const String& sessionId); |
||||
void removeSession(const String& sessionId); |
||||
void cleanExpiredSessions(); |
||||
|
||||
String getUsername() { return username; } |
||||
}; |
||||
|
||||
extern AuthManager authManager; |
||||
|
||||
#endif |
||||
@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
|
||||
#include <Arduino.h> |
||||
#include <WiFi.h> |
||||
#include <LittleFS.h> |
||||
#include <ArduinoJson.h> |
||||
#include <ArduinoOTA.h> |
||||
#include "auth.h" |
||||
#include "server.h" |
||||
#include "utils.h" |
||||
|
||||
// Configuration par défaut
|
||||
String hostname = "ESP32-Webapp"; |
||||
String wifiSSID = ""; |
||||
String wifiPassword = ""; |
||||
String apPassword = "webapp123"; |
||||
|
||||
void createDefaultConfig() { |
||||
logMessage(LOG_INFO, "Création de la configuration par défaut"); |
||||
|
||||
JsonDocument doc; |
||||
|
||||
// Configuration authentification
|
||||
doc["auth"]["username"] = "admin"; |
||||
doc["auth"]["password_hash"] = sha256("admin"); // Mot de passe par défaut: admin
|
||||
|
||||
// Configuration WiFi
|
||||
doc["wifi"]["ssid"] = ""; |
||||
doc["wifi"]["password"] = ""; |
||||
doc["wifi"]["ap_password"] = apPassword; |
||||
|
||||
// Configuration système
|
||||
doc["system"]["hostname"] = hostname; |
||||
doc["system"]["session_timeout"] = 60; |
||||
|
||||
// Paramètres application
|
||||
doc["params"]["app_name"] = "ESP32 Webapp"; |
||||
|
||||
File file = LittleFS.open("/config.json", "w"); |
||||
if (file) { |
||||
serializeJson(doc, file); |
||||
file.close(); |
||||
logMessage(LOG_INFO, "Configuration par défaut créée"); |
||||
} else { |
||||
logMessage(LOG_ERROR, "Impossible de créer config.json"); |
||||
} |
||||
} |
||||
|
||||
void loadConfig() { |
||||
File file = LittleFS.open("/config.json", "r"); |
||||
if (!file) { |
||||
logMessage(LOG_WARN, "Fichier config.json introuvable, création..."); |
||||
createDefaultConfig(); |
||||
file = LittleFS.open("/config.json", "r"); |
||||
if (!file) { |
||||
logMessage(LOG_ERROR, "Échec de la création de config.json"); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
String content = file.readString(); |
||||
file.close(); |
||||
|
||||
JsonDocument doc; |
||||
DeserializationError error = deserializeJson(doc, content); |
||||
|
||||
if (error) { |
||||
logMessage(LOG_ERROR, "Erreur de lecture de config.json"); |
||||
return; |
||||
} |
||||
|
||||
// Charger les paramètres
|
||||
if (doc["auth"]["username"]) { |
||||
String username = doc["auth"]["username"].as<String>(); |
||||
String passHash = doc["auth"]["password_hash"].as<String>(); |
||||
authManager.setCredentials(username, passHash); |
||||
} |
||||
|
||||
if (doc["system"]["hostname"]) { |
||||
hostname = doc["system"]["hostname"].as<String>(); |
||||
} |
||||
|
||||
if (doc["system"]["session_timeout"]) { |
||||
authManager.setSessionTimeout(doc["system"]["session_timeout"]); |
||||
} |
||||
|
||||
if (doc["wifi"]["ssid"]) { |
||||
wifiSSID = doc["wifi"]["ssid"].as<String>(); |
||||
} |
||||
|
||||
if (doc["wifi"]["password"]) { |
||||
wifiPassword = doc["wifi"]["password"].as<String>(); |
||||
} |
||||
|
||||
if (doc["wifi"]["ap_password"]) { |
||||
apPassword = doc["wifi"]["ap_password"].as<String>(); |
||||
} |
||||
|
||||
logMessage(LOG_INFO, "Configuration chargée"); |
||||
} |
||||
|
||||
void setupWiFi() { |
||||
if (wifiSSID.length() > 0) { |
||||
logMessage(LOG_INFO, "Connexion au WiFi: " + wifiSSID); |
||||
WiFi.mode(WIFI_STA); |
||||
WiFi.setHostname(hostname.c_str()); |
||||
WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str()); |
||||
|
||||
int attempts = 0; |
||||
while (WiFi.status() != WL_CONNECTED && attempts < 20) { |
||||
delay(500); |
||||
Serial.print("."); |
||||
attempts++; |
||||
} |
||||
|
||||
if (WiFi.status() == WL_CONNECTED) { |
||||
logMessage(LOG_INFO, "WiFi connecté"); |
||||
logMessage(LOG_INFO, "Adresse IP: " + WiFi.localIP().toString()); |
||||
return; |
||||
} |
||||
|
||||
logMessage(LOG_WARN, "Échec de connexion WiFi"); |
||||
} |
||||
|
||||
// Mode Point d'accès
|
||||
logMessage(LOG_INFO, "Démarrage en mode Point d'Accès"); |
||||
WiFi.mode(WIFI_AP); |
||||
WiFi.softAP(hostname.c_str(), apPassword.c_str()); |
||||
logMessage(LOG_INFO, "AP démarré: " + hostname); |
||||
logMessage(LOG_INFO, "Adresse IP: " + WiFi.softAPIP().toString()); |
||||
} |
||||
|
||||
void setupOTA() { |
||||
ArduinoOTA.setHostname(hostname.c_str()); |
||||
|
||||
ArduinoOTA.onStart([]() { |
||||
String type = (ArduinoOTA.getCommand() == U_FLASH) ? "firmware" : "filesystem"; |
||||
logMessage(LOG_INFO, "Début de la mise à jour OTA: " + type); |
||||
}); |
||||
|
||||
ArduinoOTA.onEnd([]() { |
||||
logMessage(LOG_INFO, "Mise à jour OTA terminée"); |
||||
}); |
||||
|
||||
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { |
||||
Serial.printf("Progression: %u%%\r", (progress / (total / 100))); |
||||
}); |
||||
|
||||
ArduinoOTA.onError([](ota_error_t error) { |
||||
Serial.printf("Erreur OTA [%u]: ", error); |
||||
if (error == OTA_AUTH_ERROR) logMessage(LOG_ERROR, "Échec d'authentification"); |
||||
else if (error == OTA_BEGIN_ERROR) logMessage(LOG_ERROR, "Échec de démarrage"); |
||||
else if (error == OTA_CONNECT_ERROR) logMessage(LOG_ERROR, "Échec de connexion"); |
||||
else if (error == OTA_RECEIVE_ERROR) logMessage(LOG_ERROR, "Échec de réception"); |
||||
else if (error == OTA_END_ERROR) logMessage(LOG_ERROR, "Échec de fin"); |
||||
}); |
||||
|
||||
ArduinoOTA.begin(); |
||||
logMessage(LOG_INFO, "OTA prêt"); |
||||
} |
||||
|
||||
void setup() { |
||||
Serial.begin(115200); |
||||
delay(100); |
||||
|
||||
logMessage(LOG_INFO, "========================================"); |
||||
logMessage(LOG_INFO, "ESP32 Webapp - Démarrage"); |
||||
logMessage(LOG_INFO, "========================================"); |
||||
|
||||
// Initialiser LittleFS
|
||||
if (!LittleFS.begin(true)) { |
||||
logMessage(LOG_ERROR, "Échec du montage de LittleFS"); |
||||
return; |
||||
} |
||||
logMessage(LOG_INFO, "LittleFS monté"); |
||||
|
||||
// Charger la configuration
|
||||
loadConfig(); |
||||
|
||||
// Initialiser le WiFi
|
||||
setupWiFi(); |
||||
|
||||
// Initialiser OTA
|
||||
setupOTA(); |
||||
|
||||
// Démarrer le serveur web
|
||||
webServer.begin(); |
||||
|
||||
logMessage(LOG_INFO, "========================================"); |
||||
logMessage(LOG_INFO, "Système prêt!"); |
||||
logMessage(LOG_INFO, "========================================"); |
||||
} |
||||
|
||||
unsigned long lastCleanup = 0; |
||||
|
||||
void loop() { |
||||
ArduinoOTA.handle(); |
||||
|
||||
// Nettoyer les sessions expirées toutes les minutes
|
||||
unsigned long now = millis(); |
||||
if (now - lastCleanup > 60000) { |
||||
authManager.cleanExpiredSessions(); |
||||
lastCleanup = now; |
||||
} |
||||
|
||||
delay(10); |
||||
} |
||||
@ -0,0 +1,394 @@
@@ -0,0 +1,394 @@
|
||||
#include "server.h" |
||||
#include "utils.h" |
||||
#include <Update.h> |
||||
|
||||
WebServerManager webServer; |
||||
|
||||
WebServerManager::WebServerManager() { |
||||
server = new AsyncWebServer(80); |
||||
} |
||||
|
||||
String WebServerManager::getSessionIdFromRequest(AsyncWebServerRequest* request) { |
||||
if (request->hasHeader("Cookie")) { |
||||
String cookie = request->header("Cookie"); |
||||
int sessionStart = cookie.indexOf("session="); |
||||
if (sessionStart != -1) { |
||||
sessionStart += 8; // longueur de "session="
|
||||
int sessionEnd = cookie.indexOf(";", sessionStart); |
||||
if (sessionEnd == -1) sessionEnd = cookie.length(); |
||||
return cookie.substring(sessionStart, sessionEnd); |
||||
} |
||||
} |
||||
return ""; |
||||
} |
||||
|
||||
bool WebServerManager::isAuthenticated(AsyncWebServerRequest* request) { |
||||
String sessionId = getSessionIdFromRequest(request); |
||||
if (sessionId.length() > 0 && authManager.validateSession(sessionId)) { |
||||
authManager.updateSessionActivity(sessionId); |
||||
return true; |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
void WebServerManager::sendJsonError(AsyncWebServerRequest* request, int code, const String& message) { |
||||
JsonDocument doc; |
||||
doc["success"] = false; |
||||
doc["message"] = message; |
||||
|
||||
String response; |
||||
serializeJson(doc, response); |
||||
request->send(code, "application/json", response); |
||||
} |
||||
|
||||
void WebServerManager::sendJsonSuccess(AsyncWebServerRequest* request, const String& message) { |
||||
JsonDocument doc; |
||||
doc["success"] = true; |
||||
doc["message"] = message; |
||||
|
||||
String response; |
||||
serializeJson(doc, response); |
||||
request->send(200, "application/json", response); |
||||
} |
||||
|
||||
void WebServerManager::setupRoutes() { |
||||
// Route de login
|
||||
server->on("/login", HTTP_POST, [this](AsyncWebServerRequest* request) { |
||||
this->handleLogin(request); |
||||
}); |
||||
|
||||
// Route de logout
|
||||
server->on("/logout", HTTP_GET, [this](AsyncWebServerRequest* request) { |
||||
this->handleLogout(request); |
||||
}); |
||||
|
||||
// API - Configuration
|
||||
server->on("/api/config", HTTP_GET, [this](AsyncWebServerRequest* request) { |
||||
if (!isAuthenticated(request)) { |
||||
sendJsonError(request, 401, "Non authentifié"); |
||||
return; |
||||
} |
||||
this->handleGetConfig(request); |
||||
}); |
||||
|
||||
server->on("/api/config", HTTP_POST, [this](AsyncWebServerRequest* request) { |
||||
if (!isAuthenticated(request)) { |
||||
sendJsonError(request, 401, "Non authentifié"); |
||||
return; |
||||
} |
||||
this->handleSetConfig(request); |
||||
}); |
||||
|
||||
// API - Changement de mot de passe
|
||||
server->on("/api/password", HTTP_POST, [this](AsyncWebServerRequest* request) { |
||||
if (!isAuthenticated(request)) { |
||||
sendJsonError(request, 401, "Non authentifié"); |
||||
return; |
||||
} |
||||
this->handleChangePassword(request); |
||||
}); |
||||
|
||||
// API - Statistiques
|
||||
server->on("/api/stats", HTTP_GET, [this](AsyncWebServerRequest* request) { |
||||
if (!isAuthenticated(request)) { |
||||
sendJsonError(request, 401, "Non authentifié"); |
||||
return; |
||||
} |
||||
this->handleGetStats(request); |
||||
}); |
||||
|
||||
// OTA Update
|
||||
server->on("/api/update", HTTP_POST, |
||||
[this](AsyncWebServerRequest* request) { |
||||
if (!isAuthenticated(request)) { |
||||
sendJsonError(request, 401, "Non authentifié"); |
||||
return; |
||||
} |
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", |
||||
Update.hasError() ? "{\"success\":false,\"message\":\"Échec de la mise à jour\"}" : |
||||
"{\"success\":true,\"message\":\"Mise à jour réussie, redémarrage...\"}"); |
||||
response->addHeader("Connection", "close"); |
||||
request->send(response); |
||||
if (!Update.hasError()) { |
||||
delay(1000); |
||||
ESP.restart(); |
||||
} |
||||
}, |
||||
[this](AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) { |
||||
this->handleOTAUpdate(request, filename, index, data, len, final); |
||||
} |
||||
); |
||||
|
||||
// 404
|
||||
server->onNotFound([](AsyncWebServerRequest* request) { |
||||
request->send(404, "text/plain", "Page non trouvée"); |
||||
}); |
||||
|
||||
// Servir les fichiers statiques (à la fin pour ne pas interférer avec les routes API)
|
||||
server->serveStatic("/", LittleFS, "/").setDefaultFile("login.html"); |
||||
} |
||||
|
||||
void WebServerManager::handleLogin(AsyncWebServerRequest* request) { |
||||
if (!request->hasParam("username", true) || !request->hasParam("password", true)) { |
||||
sendJsonError(request, 400, "Paramètres manquants"); |
||||
return; |
||||
} |
||||
|
||||
String username = request->getParam("username", true)->value(); |
||||
String password = request->getParam("password", true)->value(); |
||||
|
||||
if (authManager.authenticate(username, password)) { |
||||
String sessionId = authManager.createSession(username); |
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", |
||||
"{\"success\":true,\"message\":\"Connexion réussie\"}"); |
||||
response->addHeader("Set-Cookie", "session=" + sessionId + "; Path=/; HttpOnly; Max-Age=86400"); |
||||
request->send(response); |
||||
} else { |
||||
sendJsonError(request, 401, "Identifiants incorrects"); |
||||
} |
||||
} |
||||
|
||||
void WebServerManager::handleLogout(AsyncWebServerRequest* request) { |
||||
String sessionId = getSessionIdFromRequest(request); |
||||
if (sessionId.length() > 0) { |
||||
authManager.removeSession(sessionId); |
||||
} |
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(302); // Redirection temporaire
|
||||
response->addHeader("Location", "/"); |
||||
response->addHeader("Set-Cookie", "session=; Path=/; HttpOnly; Max-Age=0"); |
||||
request->send(response); |
||||
} |
||||
|
||||
void WebServerManager::handleGetConfig(AsyncWebServerRequest* request) { |
||||
File file = LittleFS.open("/config.json", "r"); |
||||
if (!file) { |
||||
sendJsonError(request, 500, "Impossible de lire la configuration"); |
||||
return; |
||||
} |
||||
|
||||
String content = file.readString(); |
||||
file.close(); |
||||
|
||||
JsonDocument doc; |
||||
DeserializationError error = deserializeJson(doc, content); |
||||
|
||||
if (error) { |
||||
sendJsonError(request, 500, "Erreur de lecture de la configuration"); |
||||
return; |
||||
} |
||||
|
||||
// Masquer le mot de passe
|
||||
if (doc["wifi"]["password"]) { |
||||
doc["wifi"]["password"] = "***"; |
||||
} |
||||
if (doc["wifi"]["ap_password"]) { |
||||
doc["wifi"]["ap_password"] = "***"; |
||||
} |
||||
if (doc["auth"]["password_hash"]) { |
||||
doc["auth"]["password_hash"] = "***"; |
||||
} |
||||
|
||||
String response; |
||||
serializeJson(doc, response); |
||||
request->send(200, "application/json", response); |
||||
} |
||||
|
||||
void WebServerManager::handleSetConfig(AsyncWebServerRequest* request) { |
||||
if (!request->hasParam("config", true)) { |
||||
sendJsonError(request, 400, "Configuration manquante"); |
||||
return; |
||||
} |
||||
|
||||
String configStr = request->getParam("config", true)->value(); |
||||
JsonDocument newDoc; |
||||
DeserializationError error = deserializeJson(newDoc, configStr); |
||||
|
||||
if (error) { |
||||
sendJsonError(request, 400, "Format JSON invalide"); |
||||
return; |
||||
} |
||||
|
||||
// Charger la configuration existante
|
||||
File file = LittleFS.open("/config.json", "r"); |
||||
if (!file) { |
||||
sendJsonError(request, 500, "Impossible de lire la configuration existante"); |
||||
return; |
||||
} |
||||
|
||||
String existingContent = file.readString(); |
||||
file.close(); |
||||
|
||||
JsonDocument existingDoc; |
||||
DeserializationError existingError = deserializeJson(existingDoc, existingContent); |
||||
|
||||
if (existingError) { |
||||
sendJsonError(request, 500, "Erreur de lecture de la configuration existante"); |
||||
return; |
||||
} |
||||
|
||||
// Fusionner : ne remplacer les mots de passe que s'ils ne sont pas "***"
|
||||
if (newDoc["wifi"]["password"] && newDoc["wifi"]["password"] != "***") { |
||||
existingDoc["wifi"]["password"] = newDoc["wifi"]["password"]; |
||||
} |
||||
if (newDoc["wifi"]["ap_password"] && newDoc["wifi"]["ap_password"] != "***") { |
||||
existingDoc["wifi"]["ap_password"] = newDoc["wifi"]["ap_password"]; |
||||
} |
||||
if (newDoc["auth"]["password_hash"] && newDoc["auth"]["password_hash"] != "***") { |
||||
existingDoc["auth"]["password_hash"] = newDoc["auth"]["password_hash"]; |
||||
} |
||||
|
||||
// Copier les autres champs
|
||||
if (newDoc["auth"]["username"]) { |
||||
existingDoc["auth"]["username"] = newDoc["auth"]["username"]; |
||||
} |
||||
if (newDoc["wifi"]["ssid"]) { |
||||
existingDoc["wifi"]["ssid"] = newDoc["wifi"]["ssid"]; |
||||
} |
||||
if (newDoc["system"]["hostname"]) { |
||||
existingDoc["system"]["hostname"] = newDoc["system"]["hostname"]; |
||||
} |
||||
if (newDoc["system"]["session_timeout"]) { |
||||
existingDoc["system"]["session_timeout"] = newDoc["system"]["session_timeout"]; |
||||
} |
||||
if (newDoc["params"]["app_name"]) { |
||||
existingDoc["params"]["app_name"] = newDoc["params"]["app_name"]; |
||||
} |
||||
|
||||
// Sauvegarder la configuration fusionnée
|
||||
file = LittleFS.open("/config.json", "w"); |
||||
if (!file) { |
||||
sendJsonError(request, 500, "Impossible d'écrire la configuration"); |
||||
return; |
||||
} |
||||
|
||||
serializeJson(existingDoc, file); |
||||
file.close(); |
||||
|
||||
// Attendre que LittleFS finisse l'écriture
|
||||
delay(500); |
||||
|
||||
sendJsonSuccess(request, "Configuration mise à jour avec succès"); |
||||
|
||||
// Redémarrer après un délai
|
||||
delay(1000); |
||||
ESP.restart(); |
||||
} |
||||
|
||||
void WebServerManager::handleChangePassword(AsyncWebServerRequest* request) { |
||||
if (!request->hasParam("config", true)) { |
||||
sendJsonError(request, 400, "Configuration manquante"); |
||||
return; |
||||
} |
||||
|
||||
String configStr = request->getParam("config", true)->value(); |
||||
JsonDocument newDoc; |
||||
DeserializationError error = deserializeJson(newDoc, configStr); |
||||
|
||||
if (error) { |
||||
sendJsonError(request, 400, "Format JSON invalide"); |
||||
return; |
||||
} |
||||
|
||||
// Vérifier que les champs requis sont présents
|
||||
if (!newDoc["auth"]["password_hash"] || !newDoc["auth"]["old_password"]) { |
||||
sendJsonError(request, 400, "Données manquantes"); |
||||
return; |
||||
} |
||||
|
||||
String oldPassword = newDoc["auth"]["old_password"].as<String>(); |
||||
String newPasswordHash = newDoc["auth"]["password_hash"].as<String>(); |
||||
|
||||
// Vérifier que l'ancien mot de passe est correct
|
||||
if (!authManager.authenticate(authManager.getUsername(), oldPassword)) { |
||||
sendJsonError(request, 401, "Mot de passe actuel incorrect"); |
||||
return; |
||||
} |
||||
|
||||
// Charger la configuration existante
|
||||
File file = LittleFS.open("/config.json", "r"); |
||||
if (!file) { |
||||
sendJsonError(request, 500, "Impossible de lire la configuration existante"); |
||||
return; |
||||
} |
||||
|
||||
String existingContent = file.readString(); |
||||
file.close(); |
||||
|
||||
JsonDocument existingDoc; |
||||
DeserializationError existingError = deserializeJson(existingDoc, existingContent); |
||||
|
||||
if (existingError) { |
||||
sendJsonError(request, 500, "Erreur de lecture de la configuration existante"); |
||||
return; |
||||
} |
||||
|
||||
// Mettre à jour uniquement le hash du mot de passe
|
||||
existingDoc["auth"]["password_hash"] = newPasswordHash; |
||||
|
||||
// Sauvegarder la configuration mise à jour
|
||||
file = LittleFS.open("/config.json", "w"); |
||||
if (!file) { |
||||
sendJsonError(request, 500, "Impossible d'écrire la configuration"); |
||||
return; |
||||
} |
||||
|
||||
serializeJson(existingDoc, file); |
||||
file.close(); |
||||
|
||||
// Attendre que LittleFS finisse l'écriture
|
||||
delay(500); |
||||
|
||||
// Mettre à jour les credentials en mémoire
|
||||
authManager.setCredentials(authManager.getUsername(), newPasswordHash); |
||||
|
||||
sendJsonSuccess(request, "Mot de passe changé avec succès"); |
||||
} |
||||
|
||||
void WebServerManager::handleGetStats(AsyncWebServerRequest* request) { |
||||
JsonDocument doc; |
||||
doc["uptime"] = millis() / 1000; |
||||
doc["heap_free"] = ESP.getFreeHeap(); |
||||
doc["heap_total"] = ESP.getHeapSize(); |
||||
doc["wifi_rssi"] = WiFi.RSSI(); |
||||
doc["wifi_ssid"] = WiFi.SSID(); |
||||
doc["ip"] = WiFi.localIP().toString(); |
||||
|
||||
String response; |
||||
serializeJson(doc, response); |
||||
request->send(200, "application/json", response); |
||||
} |
||||
|
||||
void WebServerManager::handleOTAUpdate(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) { |
||||
if (!index) { |
||||
logMessage(LOG_INFO, "Début de la mise à jour OTA: " + filename); |
||||
|
||||
int cmd = (filename.indexOf("spiffs") > -1 || filename.indexOf("littlefs") > -1) ? U_SPIFFS : U_FLASH; |
||||
|
||||
if (!Update.begin(UPDATE_SIZE_UNKNOWN, cmd)) { |
||||
Update.printError(Serial); |
||||
} |
||||
} |
||||
|
||||
if (len) { |
||||
if (Update.write(data, len) != len) { |
||||
Update.printError(Serial); |
||||
} |
||||
} |
||||
|
||||
if (final) { |
||||
if (Update.end(true)) { |
||||
logMessage(LOG_INFO, "Mise à jour OTA terminée avec succès"); |
||||
} else { |
||||
Update.printError(Serial); |
||||
logMessage(LOG_ERROR, "Échec de la mise à jour OTA"); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void WebServerManager::begin() { |
||||
setupRoutes(); |
||||
server->begin(); |
||||
logMessage(LOG_INFO, "Serveur web démarré sur le port 80"); |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
#ifndef SERVER_H |
||||
#define SERVER_H |
||||
|
||||
#include <WiFi.h> |
||||
#include <AsyncTCP.h> |
||||
#include <ESPAsyncWebServer.h> |
||||
#include <LittleFS.h> |
||||
#include <ArduinoJson.h> |
||||
#include "auth.h" |
||||
|
||||
class WebServerManager { |
||||
private: |
||||
AsyncWebServer* server; |
||||
|
||||
String getSessionIdFromRequest(AsyncWebServerRequest* request); |
||||
bool isAuthenticated(AsyncWebServerRequest* request); |
||||
void sendJsonError(AsyncWebServerRequest* request, int code, const String& message); |
||||
void sendJsonSuccess(AsyncWebServerRequest* request, const String& message); |
||||
|
||||
public: |
||||
WebServerManager(); |
||||
void begin(); |
||||
void setupRoutes(); |
||||
|
||||
// Handlers
|
||||
void handleLogin(AsyncWebServerRequest* request); |
||||
void handleLogout(AsyncWebServerRequest* request); |
||||
void handleGetConfig(AsyncWebServerRequest* request); |
||||
void handleSetConfig(AsyncWebServerRequest* request); |
||||
void handleChangePassword(AsyncWebServerRequest* request); |
||||
void handleGetStats(AsyncWebServerRequest* request); |
||||
void handleOTAUpdate(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final); |
||||
}; |
||||
|
||||
extern WebServerManager webServer; |
||||
|
||||
#endif |
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
#include "utils.h" |
||||
|
||||
// Niveau de log par défaut
|
||||
LogLevel currentLogLevel = LOG_INFO; |
||||
|
||||
String sha256(const String& data) { |
||||
byte hash[32]; |
||||
mbedtls_md_context_t ctx; |
||||
mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256; |
||||
|
||||
mbedtls_md_init(&ctx); |
||||
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 0); |
||||
mbedtls_md_starts(&ctx); |
||||
mbedtls_md_update(&ctx, (const unsigned char*)data.c_str(), data.length()); |
||||
mbedtls_md_finish(&ctx, hash); |
||||
mbedtls_md_free(&ctx); |
||||
|
||||
String hashString = ""; |
||||
for (int i = 0; i < 32; i++) { |
||||
char hex[3]; |
||||
sprintf(hex, "%02x", hash[i]); |
||||
hashString += hex; |
||||
} |
||||
return hashString; |
||||
} |
||||
|
||||
String generateSessionId() { |
||||
String sessionId = ""; |
||||
for (int i = 0; i < 32; i++) { |
||||
sessionId += String(random(0, 16), HEX); |
||||
} |
||||
return sessionId; |
||||
} |
||||
|
||||
unsigned long getCurrentTime() { |
||||
return millis(); |
||||
} |
||||
|
||||
void logMessage(LogLevel level, const String& message) { |
||||
if (level < currentLogLevel) return; |
||||
|
||||
String levelStr; |
||||
switch (level) { |
||||
case LOG_DEBUG: levelStr = "[DEBUG]"; break; |
||||
case LOG_INFO: levelStr = "[INFO]"; break; |
||||
case LOG_WARN: levelStr = "[WARN]"; break; |
||||
case LOG_ERROR: levelStr = "[ERROR]"; break; |
||||
} |
||||
|
||||
Serial.print(levelStr); |
||||
Serial.print(" "); |
||||
Serial.println(message); |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
#ifndef UTILS_H |
||||
#define UTILS_H |
||||
|
||||
#include <Arduino.h> |
||||
#include <mbedtls/md.h> |
||||
|
||||
// Fonction pour calculer le hash SHA-256
|
||||
String sha256(const String& data); |
||||
|
||||
// Fonction pour générer un ID de session aléatoire
|
||||
String generateSessionId(); |
||||
|
||||
// Fonction pour obtenir le temps actuel en millisecondes
|
||||
unsigned long getCurrentTime(); |
||||
|
||||
// Fonction pour logger des messages
|
||||
enum LogLevel { |
||||
LOG_DEBUG, |
||||
LOG_INFO, |
||||
LOG_WARN, |
||||
LOG_ERROR |
||||
}; |
||||
|
||||
void logMessage(LogLevel level, const String& message); |
||||
|
||||
#endif |
||||
Loading…
Reference in new issue