Browse Source

Premier commit

master
scayac 2 months ago
commit
c670cde9cf
  1. 5
      .gitignore
  2. 10
      .vscode/extensions.json
  3. 182
      README.md
  4. 30
      data/config.json
  5. 509
      data/index.html
  6. 153
      data/login.html
  7. 487
      data/settings.html
  8. 283
      data/update.html
  9. 15
      platformio.ini
  10. 1409
      src/main.cpp
  11. 11
      test/README

5
.gitignore vendored

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

182
README.md

@ -0,0 +1,182 @@ @@ -0,0 +1,182 @@
# ESP32 Contrôleur Solaire pour Chauffe-Eau
Application ESP32 avec serveur web, stockage LittleFS et mises à jour OTA.
## Fonctionnalités
- ✅ Serveur web avec stockage HTML sur LittleFS
- ✅ Protection par mot de passe (authentification SHA-256)
- ✅ Page d'accueil "Hello World" à la racine
- ✅ Interface de mise à jour OTA accessible via `/update.html`
- ✅ Mise à jour du firmware via OTA
- ✅ Mise à jour du filesystem via OTA
- ✅ Support ArduinoOTA pour mise à jour via l'IDE
## Configuration
### 1. Modifier les identifiants WiFi
Éditez `src/main.cpp` et modifiez les lignes suivantes:
```cpp
const char* ssid = "VotreSSID"; // Votre nom de réseau WiFi
const char* password = "VotreMotDePasse"; // Votre mot de passe WiFi
```
### 2. Configuration OTA (optionnel)
```cpp
const char* otaHostname = "ESP32-Controleur-Solaire"; // Nom de l'appareil sur le réseau
const char* otaPassword = "admin"; // Mot de passe pour OTA via IDE
```
### 3. Configuration de l'authentification web
Par défaut, l'accès au serveur web est protégé par mot de passe:
- **Utilisateur**: `admin`
- **Mot de passe**: `password`
#### Changer le mot de passe:
1. **Générer le hash SHA-256 de votre nouveau mot de passe:**
Sous Linux/Mac:
```bash
echo -n "votre_nouveau_mot_de_passe" | sha256sum
```
Sous Windows (PowerShell):
```powershell
$stringAsStream = [System.IO.MemoryStream]::new()
$writer = [System.IO.StreamWriter]::new($stringAsStream)
$writer.write("votre_nouveau_mot_de_passe")
$writer.Flush()
$stringAsStream.Position = 0
Get-FileHash -InputStream $stringAsStream -Algorithm SHA256 | Select-Object Hash
```
Ou utilisez un outil en ligne: https://emn178.github.io/online-tools/sha256.html
2. **Éditer le fichier `data/config.json`:**
```json
{
"auth": {
"username": "admin",
"password_hash": "VOTRE_NOUVEAU_HASH_ICI"
}
}
```
3. **Re-uploader le filesystem:**
```bash
platformio run --target uploadfs
```
**⚠ Important**: Le mot de passe est stocké sous forme de hash SHA-256 dans le fichier de configuration pour plus de sécurité.
## Installation
### Première installation (câble USB)
1. **Compiler et uploader le code:**
```bash
platformio run --target upload
```
2. **Uploader le filesystem LittleFS:**
```bash
platformio run --target uploadfs
```
### Mises à jour ultérieures
#### Via l'interface web `/update.html`:
1. Accédez à `http://[IP_de_votre_ESP]/update.html`
2. Sélectionnez le fichier `.bin` approprié:
- **Firmware**: `.pio/build/esp32/firmware.bin`
- **Filesystem**: `.pio/build/esp32/littlefs.bin`
3. Cliquez sur le bouton d'upload correspondant
#### Via ArduinoOTA (depuis PlatformIO):
```bash
# Mise à jour du firmware
platformio run --target upload --upload-port [IP_de_votre_ESP]
# Mise à jour du filesystem
platformio run --target uploadfs --upload-port [IP_de_votre_ESP]
```
## Structure du projet
```
esp32_controleur_solaire/
├── platformio.ini # Configuration PlatformIO
├── src/
│ └── main.cpp # Code principal
├── data/ # Fichiers pour LittleFS
│ ├── config.json # Configuration (hash du mot de passe)
│ ├── index.html # Console principale (tout CSS inclus inline)
│ ├── login.html # Page de connexion (tout CSS inclus inline)
│ ├── update.html # Interface de mise à jour OTA (tout CSS inclus inline)
│ └── settings.html # Page de configuration (tout CSS inclus inline)
├── include/
├── lib/
└── test/
```
## URLs disponibles
- **http://[IP]/login** - Page de connexion
- **http://[IP]/** - Console principale (protégée)
- **http://[IP]/settings.html** - Configuration (protégée)
- **http://[IP]/update.html** - Interface de mise à jour OTA (protégée)
- **http://[IP]/logout** - Déconnexion
**Note**: Toutes les pages sauf `/login` nécessitent une authentification.
## Moniteur série
Pour voir les logs de démarrage:
```bash
platformio device monitor
```
Vous verrez:
- L'adresse IP attribuée
- Le statut de montage de LittleFS
- Les informations de connexion WiFi
- Les événements OTA
## Dépannage
### Impossible de se connecter (authentification)
- Vérifiez les identifiants par défaut: `admin` / `password`
- Assurez-vous que le fichier `config.json` a été uploadé avec le filesystem
- Vérifiez les logs série pour confirmer que la configuration a été chargée
- Effacez les cookies de votre navigateur et réessayez
### LittleFS ne monte pas
- Assurez-vous d'avoir uploadé le filesystem avec `uploadfs`
- Vérifiez que `board_build.filesystem = littlefs` est dans `platformio.ini`
### Impossible de se connecter au WiFi
- Vérifiez les identifiants WiFi dans `main.cpp`
- Vérifiez la force du signal WiFi
### OTA ne fonctionne pas
- Vérifiez que l'ESP et votre ordinateur sont sur le même réseau
- Vérifiez le mot de passe OTA si vous utilisez ArduinoOTA
- Assurez-vous que le port 8266 n'est pas bloqué par un pare-feu
### Style CSS
- Tous les styles sont désormais inclus directement dans chaque fichier HTML (`<style>...</style>`).
- Le fichier `style.css` a été supprimé, il n'est plus nécessaire.
## Licence
MIT

30
data/config.json

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
{
"auth": {
"username": "admin",
"password_hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
},
"wifi": {
"ssid": "Livebox-9DBA",
"password": "keyWifi-78955",
"ap_password": "123456"
},
"system": {
"hostname": "Routeur-solaire",
"session_timeout": 60,
"heater_max_power": 2400,
"min_water_temp": 40.0,
"max_water_temp": 60.0,
"coeff_jour": 350.0,
"target_max_water_temp_hour": 17,
"latitude": 49.01023,
"longitude": 2.03552,
"night_start_hour": 0,
"night_end_hour": 4
},
"enphase": {
"gateway_ip": "192.168.0.201",
"token": "eyJraWQiOiI3ZDEwMDA1ZC03ODk5LTRkMGQtYmNiNC0yNDRmOThlZTE1NmIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiI0ODIzMzAwOTIzNTgiLCJpc3MiOiJFbnRyZXoiLCJlbnBoYXNlVXNlciI6Im93bmVyIiwiZXhwIjoxNzk5NzA1MDI3LCJpYXQiOjE3NjgxNjkwMjcsImp0aSI6IjYwOGZiMzI4LWQ2MGQtNDA5NC05YWIyLWNhYTUzN2Q2NGZiZSIsInVzZXJuYW1lIjoiY2hyaXN0b3BoZS5zY2F5YUBnbWFpbC5jb20ifQ.UtMHxb1E2_3IfTFzn6sZZ7J1Bc-cNezTo-0cio6djc_fRpHbUfvZkWmhotiQUntf_k6Kv2v3HPhmPZFE27Yy6Q",
"update_interval": 5
},
"comment": "Le hash ci-dessus correspond au mot de passe 'password'. Utilisez SHA-256 pour générer votre propre hash. session_timeout est en minutes. update_interval est en secondes. ap_password est le mot de passe du mode Access Point de secours (minimum 8 caractères)."
}

509
data/index.html

@ -0,0 +1,509 @@ @@ -0,0 +1,509 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contrôleur Solaire - Console</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 900px;
margin: 50px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-top: 30px;
}
.nav-links {
text-align: center;
margin-top: 30px;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background: #95a5a6;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 5px;
transition: background 0.3s;
}
.nav-links a:hover {
background: #7f8c8d;
}
.logout-btn {
display: inline-block;
padding: 8px 20px;
background: #e74c3c;
color: white;
text-decoration: none;
border-radius: 5px;
font-size: 14px;
transition: background 0.3s;
}
.logout-btn:hover {
background: #c0392b;
}
.header-bar {
text-align: right;
margin-bottom: 20px;
}
.error {
background: #fee;
color: #c33;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
display: none;
text-align: center;
}
.loading {
text-align: center;
color: #667eea;
font-style: italic;
}
.data-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 30px 0;
}
.data-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 15px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.3s;
}
.data-card:hover {
transform: translateY(-5px);
}
.mode-btn {
padding: 15px 25px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.mode-btn:hover {
background: #667eea;
color: white;
}
.mode-btn.active {
background: #667eea;
color: white;
}
.mode-btn.off {
border-color: #e74c3c;
color: #e74c3c;
}
.mode-btn.off.active {
background: #e74c3c;
color: white;
}
.mode-btn.on {
border-color: #27ae60;
color: #27ae60;
}
.mode-btn.on.active {
background: #27ae60;
color: white;
}
.data-card.solar {
background: linear-gradient(135deg, #ffeaa7 0%, #fdcb6e 100%);
}
.data-card.consumption {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
}
.data-card.heater {
background: linear-gradient(135deg, #ff7675 0%, #d63031 100%);
color: white;
}
.data-card.temperature {
background: linear-gradient(135deg, #a29bfe 0%, #6c5ce7 100%);
color: white;
}
.data-icon {
font-size: 32px;
margin-bottom: 5px;
}
.data-label {
font-size: 14px;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 5px;
opacity: 0.8;
}
.data-value {
font-size: 28px;
font-weight: bold;
margin-bottom: 3px;
}
.data-unit {
font-size: 14px;
opacity: 0.9;
}
.status-bar {
background: #f0f0f0;
padding: 10px 15px;
border-radius: 5px;
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #555;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #27ae60;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.progress-bar {
width: 100%;
height: 30px;
background: #ecf0f1;
border-radius: 15px;
overflow: hidden;
margin-top: 15px;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #e74c3c 0%, #c0392b 100%);
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 14px;
}
.mode-selector {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header-bar">
<a href="/logout" class="logout-btn">🔓 Déconnexion</a>
</div>
<h1> Contrôleur Solaire pour Chauffe-Eau</h1>
<div id="error" class="error"></div>
<div id="loading" class="loading">Chargement des données...</div>
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot"></div>
<span>Connecté</span>
</div>
<div style="display: flex; gap: 18px; align-items: center;">
<span id="sunriseStatus">🌅 --:--</span>
<span id="sunsetStatus">🌇 --:--</span>
<span id="lastUpdate">Dernière mise à jour: --</span>
</div>
</div>
<div id="dataDisplay" style="display:none;">
<h2>📊 Données en temps réel</h2>
<div class="data-grid">
<div class="data-card solar">
<div class="data-icon"></div>
<div class="data-label">Production Solaire</div>
<div class="data-value" id="solarValue">--</div>
<div class="data-unit">Watts</div>
</div>
<div class="data-card consumption">
<div class="data-icon"></div>
<div class="data-label">Consommation</div>
<div class="data-value" id="consumptionValue">--</div>
<div class="data-unit">Watts</div>
</div>
<div class="data-card heater">
<div class="data-icon">🔥</div>
<div class="data-label">Puissance Chauffe-Eau</div>
<div class="data-value" id="heaterValue">--</div>
<div class="data-unit">%</div>
<div class="progress-bar">
<div class="progress-fill" id="heaterProgress" style="width: 0%">0%</div>
</div>
</div>
<div class="data-card temperature">
<div class="data-icon">🌡</div>
<div class="data-label">Température Eau</div>
<div class="data-value" id="temperatureValue">--</div>
<div class="data-unit">°C</div>
</div>
</div>
<h2>🎛 Mode de Fonctionnement</h2>
<div class="mode-selector">
<button class="mode-btn" data-bit="16" onclick="toggleMode(16)">🟢 ON</button>
<button class="mode-btn" data-bit="8" onclick="toggleMode(8)">🌙 NUIT</button>
<button class="mode-btn" data-bit="4" onclick="toggleMode(4)"> SOLEIL</button>
<button class="mode-btn" data-bit="2" onclick="toggleMode(2)">☼ JOUR</button>
<button class="mode-btn" data-bit="0" onclick="toggleMode(0)">🔴 OFF</button>
</div>
<div class="nav-links">
<a href="/settings.html" > Configuration</a>
<a href="/update.html" >📦 Mise à jour OTA</a>
</div>
</div>
</div>
<script>
let refreshInterval;
let currentMode = 0; // Mode par défaut (OFF)
function showError(message) {
document.getElementById('error').textContent = message;
document.getElementById('error').style.display = 'block';
setTimeout(() => {
document.getElementById('error').style.display = 'none';
}, 5000);
}
function updateModeDisplay() {
// Mettre à jour l'affichage des boutons actifs
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.remove('active', 'on', 'off');
});
if (currentMode === 0) {
// Mode OFF
document.querySelector('[data-bit="0"]').classList.add('active', 'off');
} else if (currentMode === 16) {
// Mode ON
document.querySelector('[data-bit="16"]').classList.add('active', 'on');
} else {
// Combinaisons NUIT, SOLEIL, JOUR
if (currentMode & 8) {
document.querySelector('[data-bit="8"]').classList.add('active');
}
if (currentMode & 4) {
document.querySelector('[data-bit="4"]').classList.add('active');
}
if (currentMode & 2) {
document.querySelector('[data-bit="2"]').classList.add('active');
}
}
}
function toggleMode(bitValue) {
let newMode;
if (bitValue === 16) {
// Bouton ON: activer uniquement ON (16)
newMode = 16;
} else if (bitValue === 0) {
// Bouton OFF: désactiver tout (0)
newMode = 0;
} else {
// Boutons NUIT (8), SOLEIL (4), JOUR (2)
// Si ON ou OFF est actif, commencer avec 0
if (currentMode === 16 || currentMode === 0) {
newMode = bitValue;
} else {
// Toggle le bit
newMode = currentMode ^ bitValue;
// Si tout est désactivé, mettre à 0 (OFF)
if (newMode === 0) {
newMode = 0;
}
}
}
currentMode = newMode;
updateModeDisplay();
// Envoyer le mode au serveur
fetch('/api/mode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ mode: currentMode })
})
.then(response => {
if (response.status === 401) {
// Session expirée, rediriger vers login
window.location.href = '/login';
return;
}
if (!response.ok) {
throw new Error('Erreur lors du changement de mode');
}
return response.json();
})
.then(data => {
if (!data) return; // Éviter l'erreur si redirection 401
console.log('Mode changé:', currentMode);
})
.catch(error => {
console.error('Erreur:', error);
showError('Erreur lors du changement de mode');
});
}
function loadMode() {
fetch('/api/mode')
.then(response => {
if (response.status === 401) {
// Session expirée, rediriger vers login
window.location.href = '/login';
return;
}
return response.json();
})
.then(data => {
if (!data) return; // Éviter l'erreur si redirection 401
if (typeof data.mode === 'number') {
currentMode = data.mode;
updateModeDisplay();
}
})
.catch(error => {
console.error('Erreur chargement mode:', error);
});
}
function updateData() {
fetch('/api/data')
.then(response => {
if (response.status === 401) {
// Session expirée, rediriger vers login
window.location.href = '/login';
return;
}
if (!response.ok) {
throw new Error('Erreur réseau');
}
return response.json();
})
.then(data => {
if (!data) return; // Éviter l'erreur si redirection 401
// Masquer le loading et afficher les données
document.getElementById('loading').style.display = 'none';
document.getElementById('dataDisplay').style.display = 'block';
// Mettre à jour la production solaire
document.getElementById('solarValue').textContent =
data.solar_production.toFixed(0);
// Mettre à jour la consommation
document.getElementById('consumptionValue').textContent =
data.power_consumption.toFixed(0);
// Mettre à jour la puissance du chauffe-eau
document.getElementById('heaterValue').textContent =
data.heater_power;
// Mettre à jour la température de l'eau
document.getElementById('temperatureValue').textContent =
data.water_temperature ? data.water_temperature.toFixed(1) : '--';
// Mettre à jour les heures de lever/coucher du soleil
document.getElementById('sunriseStatus').textContent = '🌅 ' + (data.sunrise_time || '--:--');
document.getElementById('sunsetStatus').textContent = '🌇 ' + (data.sunset_time || '--:--');
// Mettre à jour la barre de progression
const progressBar = document.getElementById('heaterProgress');
progressBar.style.width = data.heater_power + '%';
progressBar.textContent = data.heater_power + '%';
// Mettre à jour l'heure de dernière mise à jour
const now = new Date();
const timeString = now.toLocaleTimeString('fr-FR');
document.getElementById('lastUpdate').textContent =
'Dernière mise à jour: ' + timeString;
})
.catch(error => {
console.error('Erreur:', error);
showError('Erreur lors de la récupération des données');
});
}
// Charger les données au démarrage
window.addEventListener('DOMContentLoaded', () => {
loadMode(); // Charger le mode actuel
updateData();
// Rafraîchir toutes les 2 secondes
refreshInterval = setInterval(updateData, 2000);
});
// Nettoyer l'intervalle quand on quitte la page
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
</body>
</html>

153
data/login.html

@ -0,0 +1,153 @@ @@ -0,0 +1,153 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contrôleur Solaire - Connexion</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 400px;
margin: 100px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
font-size: 24px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: bold;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 12px 30px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #764ba2;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.error {
background: #fee;
color: #c33;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
display: none;
text-align: center;
}
.lock-icon {
text-align: center;
font-size: 48px;
margin-bottom: 20px;
}
</style>
</head>
<body class="login-page">
<div class="container">
<div class="lock-icon">🔒</div>
<h1>Connexion requise</h1>
<div id="error" class="error"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">Nom d'utilisateur:</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Mot de passe:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Se connecter</button>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('error');
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
})
.then(response => {
if (response.ok) {
// Délai pour s'assurer que le cookie est bien enregistré
setTimeout(() => {
window.location.href = '/';
}, 100);
} else {
errorDiv.textContent = 'Identifiants incorrects';
errorDiv.style.display = 'block';
document.getElementById('password').value = '';
}
})
.catch(error => {
errorDiv.textContent = 'Erreur de connexion';
errorDiv.style.display = 'block';
});
});
</script>
</body>
</html>

487
data/settings.html

@ -0,0 +1,487 @@ @@ -0,0 +1,487 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contrôleur Solaire - Configuration</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
h2 {
color: #667eea;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
margin-top: 30px;
}
.nav-links {
text-align: center;
margin-top: 30px;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background: #95a5a6;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 5px;
transition: background 0.3s;
}
.nav-links a:hover {
background: #7f8c8d;
}
.form-section {
background: #f9f9f9;
padding: 20px;
border-radius: 5px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: bold;
}
input[type="text"],
input[type="password"],
input[type="number"] {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus {
outline: none;
border-color: #667eea;
}
.password-hint {
font-size: 12px;
color: #777;
margin-top: 5px;
}
button {
padding: 12px 30px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
margin-right: 10px;
}
button:hover {
background: #764ba2;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
button.secondary {
background: #95a5a6;
}
button.secondary:hover {
background: #7f8c8d;
}
.message {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
color: #667eea;
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<h1> Configuration</h1>
<div id="message" class="message"></div>
<div id="loading" class="loading" style="display:none;">Chargement...</div>
<form id="settingsForm">
<!-- Section Authentification -->
<h2>🔐 Authentification</h2>
<div class="form-section">
<div class="form-group">
<label for="username">Nom d'utilisateur:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="current_password">Mot de passe actuel:</label>
<input type="password" id="current_password" name="current_password" placeholder="Laisser vide pour ne pas changer">
<div class="password-hint">Requis uniquement si vous changez le mot de passe</div>
</div>
<div class="form-group">
<label for="new_password">Nouveau mot de passe:</label>
<input type="password" id="new_password" name="new_password" placeholder="Laisser vide pour garder l'actuel">
<div class="password-hint">Minimum 8 caractères recommandé</div>
</div>
<div class="form-group">
<label for="confirm_password">Confirmer le nouveau mot de passe:</label>
<input type="password" id="confirm_password" name="confirm_password" placeholder="Confirmer le nouveau mot de passe">
</div>
</div>
<!-- Section Système -->
<h2>🌐 Configuration Système</h2>
<div class="form-section">
<div class="form-group">
<label for="hostname">Nom d'hôte :</label>
<input type="text" id="hostname" name="hostname" placeholder="ESP12-OTA">
<div class="password-hint">Nom de l'appareil sur le réseau</div>
</div>
<div class="form-group">
<label for="session_timeout">Durée de session (minutes):</label>
<input type="number" id="session_timeout" name="session_timeout" min="5" max="1440" value="60">
<div class="password-hint">Durée avant déconnexion automatique (5-1440 min)</div>
</div>
<div class="form-group">
<label for="wifi_ssid">Nom du réseau (SSID):</label>
<input type="text" id="wifi_ssid" name="wifi_ssid" placeholder="Nom du réseau WiFi">
<div class="password-hint">SSID du réseau WiFi auquel se connecter</div>
</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 class="password-hint"> Attention: Un redémarrage sera nécessaire pour appliquer les changements WiFi</div>
</div>
<div class="form-group">
<label for="ap_password">Mot de passe mode AP (secours):</label>
<input type="text" id="ap_password" name="ap_password" placeholder="123456">
<div class="password-hint">Mot de passe pour se connecter à l'ESP si impossible de rejoindre le WiFi (minimum 8 caractères)</div>
</div>
</div>
<!-- Section Paramètres routeur -->
<h2> Configuration routeur</h2>
<div class="form-section">
<div class="form-group">
<label for="heater_max_power">Puissance chauffe-eau (Watts):</label>
<input type="number" id="heater_max_power" name="heater_max_power" min="500" max="5000" value="2400">
<div class="password-hint">Puissance maximale du chauffe-eau</div>
</div>
<div class="form-group">
<label for="min_water_temp">Température eau minimale (°C):</label>
<input type="number" id="min_water_temp" name="min_water_temp" min="20" max="80" step="0.1" value="40.0">
<div class="password-hint">Température minimale de l'eau du chauffe-eau (mode NUIT)</div>
</div>
<div class="form-group">
<label for="max_water_temp">Température eau maximale (°C):</label>
<input type="number" id="max_water_temp" name="max_water_temp" min="20" max="80" step="0.1" value="60.0">
<div class="password-hint">Température cible maximale de l'eau (mode JOUR)</div>
</div>
<div class="form-group">
<label for="coeff_jour">Coefficient mode JOUR:</label>
<input type="number" id="coeff_jour" name="coeff_jour" min="1" max="1000" step="1" value="350">
<div class="password-hint">Coefficient pour le calcul de puissance en mode JOUR</div>
</div>
<div class="form-group">
<label for="target_max_water_temp_hour">Heure cible température max (0-23):</label>
<input type="number" id="target_max_water_temp_hour" name="target_max_water_temp_hour" min="0" max="23" value="17">
<div class="password-hint">Heure à laquelle l'eau doit atteindre la température maximale (mode JOUR)</div>
</div>
<div class="form-group">
<label for="latitude">Latitude:</label>
<input type="number" id="latitude" name="latitude" min="-90" max="90" step="0.00001" value="49.01023">
<div class="password-hint">Latitude pour calcul lever/coucher du soleil</div>
</div>
<div class="form-group">
<label for="longitude">Longitude:</label>
<input type="number" id="longitude" name="longitude" min="-180" max="180" step="0.00001" value="2.03552">
<div class="password-hint">Longitude pour calcul lever/coucher du soleil</div>
</div>
<div class="form-group">
<label for="night_start_hour">Heure début nuit (0-23):</label>
<input type="number" id="night_start_hour" name="night_start_hour" min="0" max="23" value="0">
<div class="password-hint">Heure de début de la période nuit pour le mode NUIT</div>
</div>
<div class="form-group">
<label for="night_end_hour">Heure fin nuit (0-23):</label>
<input type="number" id="night_end_hour" name="night_end_hour" min="0" max="23" value="4">
<div class="password-hint">Heure de fin de la période nuit pour le mode NUIT</div>
</div>
</div>
<!-- Section Passerelle Enphase -->
<h2> Passerelle Enphase</h2>
<div class="form-section">
<div class="form-group">
<label for="enphase_ip">Adresse IP de la passerelle:</label>
<input type="text" id="enphase_ip" name="enphase_ip" placeholder="192.168.0.201">
<div class="password-hint">Adresse IP locale de votre passerelle Enphase Envoy</div>
</div>
<div class="form-group">
<label for="enphase_token">Token d'authentification:</label>
<input type="text" id="enphase_token" name="enphase_token" placeholder="eyJraWQiOi...">
<div class="password-hint">Token JWT Bearer pour l'API Enphase (obtenu depuis entrez.enphaseenergy.com)</div>
</div>
<div class="form-group">
<label for="update_interval">Intervalle de mise à jour (secondes):</label>
<input type="number" id="update_interval" name="update_interval" min="2" max="60" value="5">
<div class="password-hint">Fréquence de récupération des données (2-60 sec)</div>
</div>
</div>
<!-- Boutons d'action -->
<div style="margin-top: 30px;">
<button type="submit">💾 Enregistrer les modifications</button>
<button type="button" class="secondary" onclick="loadSettings()">🔄 Recharger</button>
</div>
</form>
<div class="nav-links">
<a href="/">🏠 Accueil</a>
<a href="/update.html">📦 Mise à jour OTA</a>
</div>
</div>
<script>
// Charger les paramètres au démarrage
window.addEventListener('DOMContentLoaded', loadSettings);
function showMessage(text, type) {
const messageDiv = document.getElementById('message');
messageDiv.textContent = text;
messageDiv.className = 'message ' + type;
messageDiv.style.display = 'block';
setTimeout(() => {
messageDiv.style.display = 'none';
}, 5000);
}
function loadSettings() {
const loading = document.getElementById('loading');
loading.style.display = 'block';
fetch('/api/settings')
.then(response => response.json())
.then(data => {
loading.style.display = 'none';
document.getElementById('username').value = data.auth.username || 'admin';
// Paramètres WiFi
if (data.wifi) {
document.getElementById('wifi_ssid').value = data.wifi.ssid || '';
document.getElementById('ap_password').value = data.wifi.ap_password || '123456';
// Ne pas charger le mot de passe WiFi pour des raisons de sécurité
document.getElementById('wifi_password').value = '';
}
document.getElementById('hostname').value = data.system.hostname || 'ESP12-OTA';
document.getElementById('session_timeout').value = data.system.session_timeout || 60;
document.getElementById('heater_max_power').value = data.system.heater_max_power || 2400;
document.getElementById('min_water_temp').value = data.system.min_water_temp || 40.0;
document.getElementById('max_water_temp').value = data.system.max_water_temp || 60.0;
document.getElementById('coeff_jour').value = data.system.coeff_jour || 350;
document.getElementById('target_max_water_temp_hour').value = data.system.target_max_water_temp_hour || 17;
document.getElementById('latitude').value = data.system.latitude || 49.01023;
document.getElementById('longitude').value = data.system.longitude || 2.03552;
document.getElementById('night_start_hour').value = data.system.night_start_hour || 0;
document.getElementById('night_end_hour').value = data.system.night_end_hour || 4;
// Paramètres Enphase
if (data.enphase) {
document.getElementById('enphase_ip').value = data.enphase.gateway_ip || '';
document.getElementById('enphase_token').value = data.enphase.token || '';
document.getElementById('update_interval').value = data.enphase.update_interval || 5;
}
// Réinitialiser les champs de mot de passe
document.getElementById('current_password').value = '';
document.getElementById('new_password').value = '';
document.getElementById('confirm_password').value = '';
})
.catch(error => {
loading.style.display = 'none';
showMessage('Erreur lors du chargement des paramètres', 'error');
console.error('Erreur:', error);
});
}
document.getElementById('settingsForm').addEventListener('submit', function(e) {
e.preventDefault();
const newPassword = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
const currentPassword = document.getElementById('current_password').value;
// Validation du mot de passe
if (newPassword && newPassword !== confirmPassword) {
showMessage('Les mots de passe ne correspondent pas', 'error');
return;
}
if (newPassword && !currentPassword) {
showMessage('Le mot de passe actuel est requis pour changer le mot de passe', 'error');
return;
}
if (newPassword && newPassword.length < 4) {
showMessage('Le nouveau mot de passe doit contenir au moins 4 caractères', 'error');
return;
}
// Préparer les données
const settings = {
auth: {
username: document.getElementById('username').value,
current_password: currentPassword,
new_password: newPassword
},
wifi: {
ssid: document.getElementById('wifi_ssid').value,
password: document.getElementById('wifi_password').value,
ap_password: document.getElementById('ap_password').value
},
system: {
hostname: document.getElementById('hostname').value,
session_timeout: parseInt(document.getElementById('session_timeout').value),
heater_max_power: parseInt(document.getElementById('heater_max_power').value),
min_water_temp: parseFloat(document.getElementById('min_water_temp').value),
max_water_temp: parseFloat(document.getElementById('max_water_temp').value),
coeff_jour: parseFloat(document.getElementById('coeff_jour').value),
target_max_water_temp_hour: parseInt(document.getElementById('target_max_water_temp_hour').value),
latitude: parseFloat(document.getElementById('latitude').value),
longitude: parseFloat(document.getElementById('longitude').value),
night_start_hour: parseInt(document.getElementById('night_start_hour').value),
night_end_hour: parseInt(document.getElementById('night_end_hour').value)
},
enphase: {
gateway_ip: document.getElementById('enphase_ip').value,
token: document.getElementById('enphase_token').value,
update_interval: parseInt(document.getElementById('update_interval').value)
}
};
// Envoyer les données
const loading = document.getElementById('loading');
loading.style.display = 'block';
fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings)
})
.then(response => {
loading.style.display = 'none';
if (response.ok) {
return response.json();
} else {
throw new Error('Erreur lors de la sauvegarde');
}
})
.then(data => {
const wifiChanged = document.getElementById('wifi_ssid').value || document.getElementById('wifi_password').value;
if (wifiChanged) {
showMessage(data.message + ' - L\'ESP va redémarrer pour appliquer les changements WiFi.', 'success');
setTimeout(() => {
showMessage('Redémarrage en cours... Reconnexion au nouveau réseau nécessaire.', 'error');
}, 3000);
} else {
showMessage(data.message || 'Paramètres sauvegardés avec succès!', 'success');
}
// Si le mot de passe a changé, rediriger vers login après 2 secondes
if (newPassword) {
setTimeout(() => {
window.location.href = '/logout';
}, 2000);
} else {
// Recharger les paramètres
setTimeout(() => {
loadSettings();
}, 1000);
}
})
.catch(error => {
loading.style.display = 'none';
showMessage('Erreur lors de la sauvegarde des paramètres', 'error');
console.error('Erreur:', error);
});
});
</script>
</body>
</html>

283
data/update.html

@ -0,0 +1,283 @@ @@ -0,0 +1,283 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contrôleur - Mise à jour</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
h2 {
color: #667eea;
border-bottom: none;
padding-bottom: 10px;
margin-top: 30px;
}
.nav-links {
text-align: center;
margin-top: 30px;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background: #95a5a6;
color: white;
text-decoration: none;
border-radius: 5px;
margin: 5px;
transition: background 0.3s;
}
.nav-links a:hover {
background: #7f8c8d;
}
input[type="file"] {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 12px 30px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
margin-right: 10px;
}
button:hover {
background: #764ba2;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.message {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.update-section {
margin: 30px 0;
padding: 25px;
border: 2px solid #667eea;
border-radius: 8px;
}
/* Styles spécifiques à update.html */
button {
width: 100%;
}
.progress {
width: 100%;
height: 30px;
background: #f0f0f0;
border-radius: 5px;
overflow: hidden;
margin: 15px 0;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h1>🔄 Mise à jour OTA</h1>
<div class="update-section">
<h2>📱 Mise à jour du Firmware</h2>
<p>Sélectionnez un fichier .bin pour mettre à jour le firmware de l'ESP</p>
<input type="file" id="firmwareFile" accept=".bin">
<div class="progress" id="firmwareProgress">
<div class="progress-bar" id="firmwareProgressBar">0%</div>
</div>
<div class="message" id="firmwareMessage"></div>
<button onclick="uploadFirmware()" id="firmwareBtn">Envoyer le Firmware</button>
</div>
<div class="update-section">
<h2>📁 Mise à jour du Filesystem</h2>
<p>Sélectionnez un fichier .bin du filesystem LittleFS</p>
<input type="file" id="filesystemFile" accept=".bin">
<div class="progress" id="filesystemProgress">
<div class="progress-bar" id="filesystemProgressBar">0%</div>
</div>
<div class="message" id="filesystemMessage"></div>
<button onclick="uploadFilesystem()" id="filesystemBtn">Envoyer le Filesystem</button>
</div>
<div class="nav-links">
<a href="/">🏠 Accueil</a>
<a href="/settings.html"> Configuration</a>
</div>
</div>
<script>
function showMessage(type, elementId, message) {
const msgElement = document.getElementById(elementId);
msgElement.className = 'message ' + type;
msgElement.textContent = message;
msgElement.style.display = 'block';
}
function updateProgress(progressBarId, percent) {
const bar = document.getElementById(progressBarId);
bar.style.width = percent + '%';
bar.textContent = percent + '%';
}
function uploadFirmware() {
const fileInput = document.getElementById('firmwareFile');
const file = fileInput.files[0];
if (!file) {
showMessage('error', 'firmwareMessage', 'Veuillez sélectionner un fichier');
return;
}
const formData = new FormData();
formData.append('firmware', file);
const button = document.getElementById('firmwareBtn');
button.disabled = true;
document.getElementById('firmwareProgress').style.display = 'block';
document.getElementById('firmwareMessage').style.display = 'none';
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
updateProgress('firmwareProgressBar', percent);
}
});
xhr.addEventListener('load', () => {
button.disabled = false;
if (xhr.status === 200) {
showMessage('success', 'firmwareMessage', 'Firmware mis à jour avec succès! Redémarrage en cours...');
setTimeout(() => {
window.location.href = '/index.html';
}, 5000);
} else {
showMessage('error', 'firmwareMessage', 'Erreur lors de la mise à jour: ' + xhr.responseText);
}
});
xhr.addEventListener('error', () => {
button.disabled = false;
showMessage('error', 'firmwareMessage', 'Erreur de connexion');
});
xhr.open('POST', '/update');
xhr.send(formData);
}
function uploadFilesystem() {
const fileInput = document.getElementById('filesystemFile');
const file = fileInput.files[0];
if (!file) {
showMessage('error', 'filesystemMessage', 'Veuillez sélectionner un fichier');
return;
}
const formData = new FormData();
formData.append('filesystem', file);
const button = document.getElementById('filesystemBtn');
button.disabled = true;
document.getElementById('filesystemProgress').style.display = 'block';
document.getElementById('filesystemMessage').style.display = 'none';
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
updateProgress('filesystemProgressBar', percent);
}
});
xhr.addEventListener('load', () => {
button.disabled = false;
if (xhr.status === 200) {
showMessage('success', 'filesystemMessage', 'Filesystem mis à jour avec succès! Redémarrage en cours...');
setTimeout(() => {
window.location.href = '/index.html';
}, 5000);
} else {
showMessage('error', 'filesystemMessage', 'Erreur lors de la mise à jour: ' + xhr.responseText);
}
});
xhr.addEventListener('error', () => {
button.disabled = false;
showMessage('error', 'filesystemMessage', 'Erreur de connexion');
});
xhr.open('POST', '/update?filesystem=true');
xhr.send(formData);
}
</script>
</body>
</html>

15
platformio.ini

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
[env:esp32]
platform = espressif32
board = esp32dev
framework = arduino
board_build.filesystem = littlefs
monitor_speed = 115200
lib_deps =
https://github.com/me-no-dev/ESPAsyncWebServer.git
https://github.com/me-no-dev/AsyncTCP.git
bblanchon/ArduinoJson@^7.2.1
; Configuration ArduinoOTA pour firmware et filesystem
;upload_protocol = espota
;upload_port = 192.168.0.28
;upload_flags =
; --auth=password

1409
src/main.cpp

File diff suppressed because it is too large Load Diff

11
test/README

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
Loading…
Cancel
Save