Browse Source

Initial commit

master
scayac 2 months ago
commit
9aacc00631
  1. 109
      .github/prompts/structure.prompt.md
  2. 6
      .github/prompts/update-nom-app.prompt.md
  3. 6
      .github/prompts/update_doc.prompt.md
  4. 26
      .gitignore
  5. 258
      README.md
  6. 58
      compress_assets.py
  7. 327
      data/css/styles.css
  8. BIN
      data/css/styles.css.gz
  9. 59
      data/index.html
  10. BIN
      data/index.html.gz
  11. 101
      data/js/api.js
  12. BIN
      data/js/api.js.gz
  13. 75
      data/js/app.js
  14. BIN
      data/js/app.js.gz
  15. 1
      data/js/crypto-js.min.js
  16. BIN
      data/js/crypto-js.min.js.gz
  17. 70
      data/login.html
  18. BIN
      data/login.html.gz
  19. 199
      data/settings.html
  20. BIN
      data/settings.html.gz
  21. 302
      data/update.html
  22. BIN
      data/update.html.gz
  23. 18
      platformio.ini
  24. 37
      platformio_extra.py
  25. 89
      src/auth.cpp
  26. 37
      src/auth.h
  27. 205
      src/main.cpp
  28. 394
      src/server.cpp
  29. 37
      src/server.h
  30. 53
      src/utils.cpp
  31. 26
      src/utils.h

109
.github/prompts/structure.prompt.md

@ -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": {
}
}
```

6
.github/prompts/update-nom-app.prompt.md

@ -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.

6
.github/prompts/update_doc.prompt.md

@ -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

26
.gitignore vendored

@ -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

258
README.md

@ -0,0 +1,258 @@ @@ -0,0 +1,258 @@
# ESP32 Webapp
Squelette d'application web pour ESP32 avec interface de gestion via dashboard web responsive.
## 🔔 Fonctionnalités
- **Dashboard web responsive** - Interface utilisateur moderne et adaptative
- **Authentification sécurisée** - Système de login avec sessions et hash SHA-256
- **Configuration WiFi** - Mode Station et Point d'Accès automatique
- **API REST** - Endpoints pour configuration, statistiques et changement de mot de passe
- **Gestion des mots de passe** - Changement sécurisé avec validation de l'ancien mot de passe
- **Mise à jour OTA** - Mise à jour du firmware et système de fichiers via web avec modales de confirmation
- **Compression gzip** - Optimisation automatique des assets (~70% de réduction)
- **Système de fichiers LittleFS** - Stockage de configuration et fichiers web
## 📋 Prérequis
- ESP32
- PlatformIO
- Câble USB pour le téléversement initial
## 🚀 Installation
### 1. Compiler le projet
```bash
platformio run --environment esp32
```
### 2. Téléverser le firmware
```bash
platformio run --environment esp32 --target upload
```
### 3. Téléverser le système de fichiers (data/)
```bash
platformio run --environment esp32 --target uploadfs
```
### 4. Moniteur série (optionnel)
```bash
platformio device monitor --environment esp32
```
## 🔐 Connexion initiale
### Première connexion
1. Au premier démarrage, l'ESP32 crée un point d'accès WiFi :
- **SSID** : `ESP32-Webapp`
- **Mot de passe** : `webapp123`
2. Connectez-vous au point d'accès depuis votre appareil
3. Ouvrez un navigateur web et accédez à : `http://192.168.4.1`
4. Identifiants par défaut :
- **Utilisateur** : `admin`
- **Mot de passe** : `admin`
**Important** : Changez le mot de passe par défaut après la première connexion!
## ⚙ Configuration WiFi
### Configurer la connexion WiFi Station
1. Accédez à la page **Paramètres**
2. Section **Configuration WiFi**
3. Entrez le SSID et le mot de passe de votre réseau
4. Cliquez sur **Enregistrer WiFi**
5. L'ESP32 redémarre et se connecte à votre réseau
### Changer le mot de passe
1. Accédez à la page **Paramètres**
2. Section **Changer le mot de passe**
3. Entrez votre mot de passe actuel
4. Entrez le nouveau mot de passe et confirmez
5. Cliquez sur **Changer le mot de passe**
6. Vous serez automatiquement redirigé vers la connexion
### Mode Point d'Accès
Si la connexion WiFi échoue ou n'est pas configurée, l'ESP32 démarre automatiquement en mode Point d'Accès (AP).
## 📱 Interface Web
### Pages disponibles
- **Tableau de bord** (`/index.html`) - Statistiques et contrôles
- **Paramètres** (`/settings.html`) - Configuration WiFi, système, mot de passe
- **Mise à jour** (`/update.html`) - Mise à jour OTA du firmware
- **Connexion** (`/login.html`) - Page d'authentification
### Endpoints API
#### Configuration
- `GET /api/config` - Récupérer la configuration
- `POST /api/config` - Enregistrer la configuration
#### Authentification
- `POST /login` - Se connecter (username + password)
- `GET /logout` - Se déconnecter (redirection 302 vers `/`)
#### Gestion des mots de passe
- `POST /api/password` - Changer le mot de passe (validation de l'ancien mot de passe requise)
#### Statistiques
- `GET /api/stats` - Récupérer les statistiques système (uptime, heap, WiFi, IP)
#### Mise à jour
- `POST /api/update` - Mise à jour OTA (firmware ou filesystem)
## 🔧 Structure du projet
```
esp32Webapp/
├── src/
│ ├── main.cpp # Point d'entrée
│ ├── auth.h/cpp # Gestion authentification
│ ├── server.h/cpp # Serveur web et API
│ └── utils.h/cpp # Fonctions utilitaires
├── data/
│ ├── config.json # Configuration par défaut
│ ├── index.html # Dashboard
│ ├── login.html # Page de connexion
│ ├── settings.html # Page paramètres
│ ├── update.html # Page mise à jour OTA
│ ├── js/
│ │ ├── api.js # Client API
│ │ ├── app.js # Logique UI
│ │ └── crypto-js.min.js # Hachage SHA-256 côté client
│ └── css/
│ └── styles.css # Styles CSS
├── platformio.ini # Configuration PlatformIO
├── platformio_extra.py # Script d'optimisation (compression gzip)
├── compress_assets.py # Compression assets CSS/JS/HTML
└── README.md # Cette documentation
```
## 🔄 Mise à jour OTA
### Via l'interface web
1. Compilez votre nouveau firmware : `platformio run --environment esp32`
2. Accédez à la page **Mise à jour**
3. Sélectionnez le fichier `.bin` dans `.pio/build/esp32/firmware.bin`
4. Confirmez via le modal de confirmation
5. Attendez la fin du téléversement et le redémarrage automatique
### Mise à jour du syst\u00e8me de fichiers
1. Compilez l'image filesystem : `platformio run --environment esp32 --target buildfs`
2. Accédez à la page **Mise à jour**
3. Sélectionnez le fichier `.bin` de filesystem
4. Confirmez via le modal (⚠ toutes les données seront effacées)
5. Le système redémarrera automatiquement
### Compression automatique des assets
Tous les assets CSS, JS et HTML sont compressés automatiquement en gzip avant la construction du filesystem :
- **Script** : `compress_assets.py` compresse les fichiers
- **Hook** : `platformio_extra.py` l'exécute automatiquement avant `buildfs`
- **Avantage** : ~70% de réduction de taille des fichiers statiques
## 📊 Configuration (config.json)
```json
{
"auth": {
"username": "admin",
"password_hash": "hash_sha256_du_mot_de_passe"
},
"wifi": {
"ssid": "Nom_du_réseau",
"password": "mot_de_passe_wifi",
"ap_password": "webapp123"
},
"system": {
"hostname": "ESP32-Webapp",
"session_timeout": 60
},
"params": {
"app_name": "ESP32 Webapp"
}
}
```
## 🛠 Dépendances
- **ESPAsyncWebServer** - Serveur web asynchrone
- **AsyncTCP** - TCP asynchrone pour ESP32
- **ArduinoJson** - Gestion JSON
- **LittleFS** - Système de fichiers
- **mbedtls** - Cryptographie (SHA-256)
- **Python 3** - Pour scripts d'optimisation (compression gzip)
- **CryptoJS** - Hachage SHA-256 côté client (inclus dans les assets statiques)
## 🐛 Dépannage
### L'ESP32 ne se connecte pas au WiFi
1. Vérifiez le SSID et le mot de passe dans la configuration
2. Assurez-vous que le réseau WiFi est en 2.4 GHz (l'ESP32 ne supporte pas le 5 GHz)
3. L'ESP32 passera automatiquement en mode AP si la connexion échoue
### Impossible de se connecter à l'interface web
1. Vérifiez que vous êtes connecté au même réseau que l'ESP32
2. En mode AP, l'adresse est toujours `192.168.4.1`
3. En mode Station, trouvez l'IP dans les logs du moniteur série
4. Désactivez le cache du navigateur (Ctrl+F5)
### Erreur lors de la mise à jour OTA
1. Vérifiez que le fichier .bin est correct
2. Assurez-vous d'avoir une connexion stable
3. Ne débranchez jamais l'ESP32 pendant la mise à jour
4. Après une mise à jour réussie, vous serez automatiquement redirigé vers la page de connexion
### Message de succès mais page reste en erreur
C'est normal ! La connexion se ferme intentionnellement après la mise à jour (l'ESP redémarre). Vous serez redirigé automatiquement vers la connexion après 3 secondes.
## 📝 Logs
Les logs sont disponibles via le moniteur série :
```bash
platformio device monitor --environment esp32 --baud 115200
```
## 🔒 Sécurité
- Les mots de passe sont hashés en SHA-256 (CryptoJS côté client, mbedtls côté serveur)
- Changement de mot de passe sécurisé avec validation de l'ancien mot de passe
- Les sessions ont un timeout configurable (60 minutes par défaut)
- Les cookies de session sont HttpOnly
- Authentification requise pour tous les endpoints API
- Compression gzip des assets pour réduire la surface d'attaque
- Configuration sécurisée : les vrais mots de passe ne sont jamais exposés sur le frontend (masqués en "***")
- Redirection automatique vers login après déconnexion ou mise à jour OTA
- Pas de dépendance externe obligatoire (crypto-js inclus dans les assets statiques)
## 📄 Licence
Ce projet est sous licence libre.
## 👨💻 Auteur
Développé pour ESP32
---
**Note** : Cette application est un template de base. Adaptez-la selon vos besoins spécifiques!

58
compress_assets.py

@ -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)

327
data/css/styles.css

@ -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;
}
}

BIN
data/css/styles.css.gz

Binary file not shown.

59
data/index.html

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de bord - 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" class="active">Tableau de bord</a>
<a href="/settings.html">Paramètres</a>
<a href="/update.html">Mise à jour</a>
<a href="/logout">Déconnexion</a>
</div>
</nav>
<div class="container">
<h1>Tableau de bord</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Temps de fonctionnement</div>
<div class="stat-value" id="uptime">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Mémoire libre</div>
<div class="stat-value" id="memory">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Signal WiFi</div>
<div class="stat-value" id="rssi">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Adresse IP</div>
<div class="stat-value" id="ip">--</div>
</div>
</div>
<div class="card">
<h2>Contrôles</h2>
<div class="controls-grid">
<button class="btn btn-primary" onclick="refreshStats()">🔄 Actualiser</button>
<button class="btn btn-secondary" onclick="location.href='/settings.html'"> Paramètres</button>
</div>
</div>
<div id="message" class="message"></div>
</div>
<script src="/js/api.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

BIN
data/index.html.gz

Binary file not shown.

101
data/js/api.js

@ -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();
}

BIN
data/js/api.js.gz

Binary file not shown.

75
data/js/app.js

@ -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);
}
});

BIN
data/js/app.js.gz

Binary file not shown.

1
data/js/crypto-js.min.js vendored

File diff suppressed because one or more lines are too long

BIN
data/js/crypto-js.min.js.gz

Binary file not shown.

70
data/login.html

@ -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>

BIN
data/login.html.gz

Binary file not shown.

199
data/settings.html

@ -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>

BIN
data/settings.html.gz

Binary file not shown.

302
data/update.html

@ -0,0 +1,302 @@ @@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mise à jour - ESP32 Webapp</title>
<link rel="stylesheet" href="/css/styles.css">
<style>
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content h2 {
margin-top: 0;
color: #333;
}
.modal-content p {
color: #666;
margin: 1rem 0;
}
.modal-buttons {
display: flex;
gap: 1rem;
margin-top: 2rem;
justify-content: flex-end;
}
.btn-danger {
background-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
}
</style>
</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">Paramètres</a>
<a href="/update.html" class="active">Mise à jour</a>
<a href="/logout">Déconnexion</a>
</div>
</nav>
<div class="container">
<h1>Mise à jour OTA</h1>
<div class="card warning">
<h3> Attention</h3>
<ul>
<li>Ne débranchez pas l'ESP32 pendant la mise à jour</li>
<li>La mise à jour du système de fichiers effacera toutes les données</li>
<li>L'ESP32 redémarrera automatiquement après la mise à jour</li>
<li>Vous devrez vous reconnecter après le redémarrage</li>
</ul>
</div>
<div id="message" class="message"></div>
<div class="card">
<h2>Mise à jour du firmware</h2>
<p>Sélectionnez un fichier <strong>.bin</strong> pour mettre à jour le firmware de l'ESP32.</p>
<form id="firmwareForm">
<div class="form-group">
<label for="firmwareFile">Fichier firmware (.bin)</label>
<input type="file" id="firmwareFile" name="firmwareFile" accept=".bin" required>
</div>
<button type="submit" class="btn btn-primary">Mettre à jour le firmware</button>
</form>
<div class="progress-container" id="firmwareProgress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="firmwareProgressFill"></div>
</div>
<div class="progress-text" id="firmwareProgressText">0%</div>
</div>
</div>
<div class="card">
<h2>Mise à jour du système de fichiers</h2>
<p>Sélectionnez un fichier <strong>.bin</strong> contenant le système de fichiers LittleFS.</p>
<form id="filesystemForm">
<div class="form-group">
<label for="filesystemFile">Fichier système de fichiers (.bin)</label>
<input type="file" id="filesystemFile" name="filesystemFile" accept=".bin" required>
</div>
<button type="submit" class="btn btn-primary">Mettre à jour le système de fichiers</button>
</form>
<div class="progress-container" id="filesystemProgress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="filesystemProgressFill"></div>
</div>
<div class="progress-text" id="filesystemProgressText">0%</div>
</div>
</div>
</div>
<!-- Modal de confirmation -->
<div id="confirmModal" class="modal" style="display: none;">
<div class="modal-content">
<h2 id="modalTitle">Confirmation</h2>
<p id="modalMessage"></p>
<div class="modal-buttons">
<button id="modalCancel" class="btn btn-secondary">Annuler</button>
<button id="modalConfirm" class="btn btn-danger">Confirmer</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
function showMessage(text, type = 'info') {
const messageDiv = document.getElementById('message');
messageDiv.className = `message ${type}`;
messageDiv.textContent = text;
}
function updateProgress(progressId, textId, percent) {
document.getElementById(progressId).style.width = percent + '%';
document.getElementById(textId).textContent = percent + '%';
}
function showConfirmModal(title, message) {
return new Promise((resolve) => {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalMessage').textContent = message;
document.getElementById('confirmModal').style.display = 'flex';
const confirmBtn = document.getElementById('modalConfirm');
const cancelBtn = document.getElementById('modalCancel');
const handleConfirm = () => {
cleanup();
resolve(true);
};
const handleCancel = () => {
cleanup();
resolve(false);
};
const cleanup = () => {
document.getElementById('confirmModal').style.display = 'none';
confirmBtn.removeEventListener('click', handleConfirm);
cancelBtn.removeEventListener('click', handleCancel);
};
confirmBtn.addEventListener('click', handleConfirm);
cancelBtn.addEventListener('click', handleCancel);
});
}
async function uploadFile(file, progressContainerId, progressId, textId) {
const formData = new FormData();
formData.append('update', file);
document.getElementById(progressContainerId).style.display = 'block';
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
updateProgress(progressId, textId, percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
updateProgress(progressId, textId, 100);
resolve(xhr.responseText);
} else {
reject(new Error('Erreur de mise à jour'));
}
});
xhr.addEventListener('error', () => {
// Pendant une mise à jour OTA, la connexion se ferme intentionnellement
// Considérer comme un succès si le statut n'a pas d'erreur avant
if (xhr.status === 0) {
resolve('Connexion fermée (mise à jour en cours)');
} else {
reject(new Error('Erreur de connexion'));
}
});
xhr.addEventListener('abort', () => {
resolve('Connexion fermée (mise à jour en cours)');
});
xhr.open('POST', '/api/update');
xhr.send(formData);
});
}
document.getElementById('firmwareForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('firmwareFile');
const file = fileInput.files[0];
if (!file) {
showMessage('Veuillez sélectionner un fichier', 'error');
return;
}
const confirmed = await showConfirmModal(
'Confirmation',
'Ne débranchez pas l\'ESP32 pendant la mise à jour. Continuer?'
);
if (!confirmed) {
return;
}
try {
showMessage('Mise à jour du firmware en cours...', 'info');
await uploadFile(file, 'firmwareProgress', 'firmwareProgressFill', 'firmwareProgressText');
showMessage('Firmware mis à jour avec succès! L\'ESP32 redémarre...', 'success');
setTimeout(() => {
window.location.href = '/logout';
}, 3000);
} catch (error) {
showMessage('Erreur lors de la mise à jour du firmware', 'error');
document.getElementById('firmwareProgress').style.display = 'none';
}
});
document.getElementById('filesystemForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('filesystemFile');
const file = fileInput.files[0];
if (!file) {
showMessage('Veuillez sélectionner un fichier', 'error');
return;
}
const confirmed = await showConfirmModal(
'Confirmation',
'La mise à jour du système de fichiers effacera toutes les données. Continuer?'
);
if (!confirmed) {
return;
}
try {
showMessage('Mise à jour du système de fichiers en cours...', 'info');
await uploadFile(file, 'filesystemProgress', 'filesystemProgressFill', 'filesystemProgressText');
showMessage('Système de fichiers mis à jour avec succès! L\'ESP32 redémarre...', 'success');
setTimeout(() => {
window.location.href = '/logout';
}, 3000);
} catch (error) {
showMessage('Erreur lors de la mise à jour du système de fichiers', 'error');
document.getElementById('filesystemProgress').style.display = 'none';
}
});
</script>
</body>
</html>

BIN
data/update.html.gz

Binary file not shown.

18
platformio.ini

@ -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

37
platformio_extra.py

@ -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)

89
src/auth.cpp

@ -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;
}
}
}

37
src/auth.h

@ -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

205
src/main.cpp

@ -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);
}

394
src/server.cpp

@ -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");
}

37
src/server.h

@ -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

53
src/utils.cpp

@ -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);
}

26
src/utils.h

@ -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…
Cancel
Save