commit
9aacc00631
31 changed files with 2493 additions and 0 deletions
@ -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 @@ |
|||||||
|
--- |
||||||
|
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 @@ |
|||||||
|
--- |
||||||
|
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 @@ |
|||||||
|
# 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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
/* 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 @@ |
|||||||
|
// 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 @@ |
|||||||
|
// 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 @@ |
|||||||
|
<!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 @@ |
|||||||
|
<!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 @@ |
|||||||
|
[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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
#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 @@ |
|||||||
|
#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 @@ |
|||||||
|
#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 @@ |
|||||||
|
#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 @@ |
|||||||
|
#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 @@ |
|||||||
|
#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 @@ |
|||||||
|
#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