feat: gestion WiFi — NVS credentials + scan + UI Gruvbox

- network.h/.cpp : lecture credentials NVS (Preferences) au boot,
  fallback config.h si vide ; network_get_ssid() et
  network_save_credentials() exposés
- web_server.cpp : 3 nouvelles routes REST
    GET /api/wifi/current  → SSID/IP/RSSI/modeAP
    GET /api/wifi/networks → scan async + polling état
    POST /api/wifi/connect → sauvegarde NVS + ESP.restart()
- index.html : modal WiFi (réseau actuel, liste scannée avec
  barres signal ▁▂▃▄▅ + cadenas, formulaire SSID/mdp,
  bouton œil, message redémarrage avec lien esp_jardin.local)
  design Gruvbox Seventies cohérent avec le reste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 20:34:46 +02:00
parent 22e379962c
commit 87d33b41c7
4 changed files with 463 additions and 2 deletions
+346
View File
@@ -237,6 +237,99 @@ input.fi:focus{border-color:var(--accent)}
:root[data-theme="dark"] *::-webkit-scrollbar{width:6px;height:6px}
:root[data-theme="dark"] *::-webkit-scrollbar-track{background:var(--bg-2)}
:root[data-theme="dark"] *::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
/* ── Modal WiFi ── */
.wifi-modal-backdrop{
display:none;position:fixed;inset:0;
background:rgba(0,0,0,.7);z-index:200;
align-items:flex-start;justify-content:center;
padding-top:60px;overflow-y:auto;
}
.wifi-modal-backdrop.open{display:flex}
.wifi-modal{
background:var(--bg-2);border:1px solid var(--border-2);
border-radius:12px;padding:24px;width:min(96vw,480px);
box-shadow:0 8px 40px rgba(0,0,0,.6);
display:flex;flex-direction:column;gap:18px;
margin-bottom:60px;
}
.wifi-modal-header{
display:flex;justify-content:space-between;align-items:center;
}
.wifi-modal-titre{font-size:15px;font-weight:700;color:var(--ink-1);letter-spacing:.04em}
/* Bloc réseau actuel */
.wifi-current{
background:var(--bg-3);border-radius:10px;border:1px solid var(--border-1);
padding:14px 16px;display:flex;flex-direction:column;gap:6px;
}
.wifi-current-titre{
font-size:11px;font-weight:600;text-transform:uppercase;
letter-spacing:.08em;color:var(--ink-3);margin-bottom:4px;
}
.wifi-current-row{display:flex;justify-content:space-between;align-items:center;gap:8px;}
.wifi-current-label{font-size:12px;color:var(--ink-3);font-family:var(--font-ui);}
.wifi-current-val{font-family:var(--font-mono);font-size:13px;color:var(--ink-1);}
/* Liste réseaux */
.wifi-scan-header{display:flex;align-items:center;justify-content:space-between;gap:8px}
.wifi-scan-titre{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
.wifi-networks-list{
display:flex;flex-direction:column;gap:4px;
max-height:220px;overflow-y:auto;
background:var(--bg-3);border-radius:10px;border:1px solid var(--border-1);
padding:6px;
}
.wifi-net-item{
display:flex;align-items:center;gap:10px;
padding:8px 10px;border-radius:8px;cursor:pointer;
transition:background .15s;border:1px solid transparent;
}
.wifi-net-item:hover{background:var(--bg-4);border-color:var(--border-2)}
.wifi-net-ssid{flex:1;font-size:13px;color:var(--ink-1);font-family:var(--font-ui);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wifi-net-signal{font-family:var(--font-terminal);font-size:14px;color:var(--blue);letter-spacing:-.05em;flex-shrink:0}
.wifi-net-lock{font-size:12px;color:var(--ink-3);flex-shrink:0}
.wifi-scan-msg{
font-size:12px;color:var(--ink-3);font-family:var(--font-terminal);
padding:12px;text-align:center;
}
/* Formulaire connexion */
.wifi-form-titre{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3)}
.wifi-form{display:flex;flex-direction:column;gap:10px}
.wifi-field{display:flex;flex-direction:column;gap:4px}
.wifi-label{font-size:11px;color:var(--ink-3);letter-spacing:.04em}
.wifi-input{
background:var(--bg-3);border:1px solid var(--border-2);border-radius:8px;
color:var(--ink-1);font-family:var(--font-mono);font-size:13px;
padding:8px 12px;outline:none;width:100%;
transition:border-color .15s;
}
.wifi-input:focus{border-color:var(--accent)}
.wifi-pass-wrap{position:relative;display:flex;align-items:center}
.wifi-pass-wrap .wifi-input{padding-right:38px}
.wifi-pass-toggle{
position:absolute;right:8px;background:none;border:none;
cursor:pointer;color:var(--ink-3);font-size:16px;padding:2px;
line-height:1;transition:color .15s;
}
.wifi-pass-toggle:hover{color:var(--ink-1)}
.btn-wifi-save{
background:var(--accent);color:#1a1a1a;font-weight:700;
border:none;border-radius:9px;padding:10px 18px;
cursor:pointer;font-size:13px;font-family:var(--font-ui);
letter-spacing:.04em;transition:background .15s;margin-top:4px;
}
.btn-wifi-save:hover{background:var(--accent-soft);color:#fff}
.btn-wifi-save:disabled{background:var(--bg-4);color:var(--ink-4);cursor:not-allowed}
.wifi-feedback{
font-size:12px;font-family:var(--font-terminal);min-height:18px;
text-align:center;color:var(--ok);
}
.btn-scan{
background:var(--bg-4);border:1px solid var(--border-2);
color:var(--ink-2);cursor:pointer;border-radius:7px;
padding:5px 12px;font-size:12px;font-family:var(--font-ui);
transition:background .15s,color .15s;
}
.btn-scan:hover{background:var(--bg-3);color:var(--ink-1)}
.btn-scan:disabled{cursor:not-allowed;color:var(--ink-4)}
</style>
</head>
<body>
@@ -248,6 +341,7 @@ input.fi:focus{border-color:var(--accent)}
<div id="rssiBadge" class="rssi-badge">RSSI —</div>
<div class="header-gap"></div>
<button class="btn-icon btn-cfg" id="btnCfgMobile" title="Configuration">&#9881;</button>
<button class="btn-icon" id="btnWifi" title="Gestion WiFi">&#128246;</button>
<button class="btn-icon" id="btnTheme" title="Basculer thème">&#9790;</button>
</header>
@@ -394,6 +488,73 @@ input.fi:focus{border-color:var(--accent)}
</div>
</div>
<!-- ══ MODAL WIFI ═════════════════════════════════════════════════════ -->
<div class="wifi-modal-backdrop" id="wifiModalBackdrop">
<div class="wifi-modal">
<!-- En-tête -->
<div class="wifi-modal-header">
<span class="wifi-modal-titre">&#128246; Gestion WiFi</span>
<button class="btn-close" id="btnCloseWifi">&#10005;</button>
</div>
<!-- Réseau actuel -->
<div class="wifi-current">
<div class="wifi-current-titre">Réseau actuel</div>
<div class="wifi-current-row">
<span class="wifi-current-label">SSID</span>
<span class="wifi-current-val" id="wifiCurSsid"></span>
</div>
<div class="wifi-current-row">
<span class="wifi-current-label">Adresse IP</span>
<span class="wifi-current-val" id="wifiCurIp"></span>
</div>
<div class="wifi-current-row">
<span class="wifi-current-label">RSSI</span>
<span class="wifi-current-val" id="wifiCurRssi"></span>
</div>
<div class="wifi-current-row">
<span class="wifi-current-label">Mode</span>
<span class="wifi-current-val" id="wifiCurMode"></span>
</div>
</div>
<!-- Scanner réseaux -->
<div>
<div class="wifi-scan-header">
<span class="wifi-scan-titre">Réseaux disponibles</span>
<button class="btn-scan" id="btnScan">&#128270; Scanner</button>
</div>
<div style="margin-top:8px">
<div class="wifi-networks-list" id="wifiNetworksList">
<div class="wifi-scan-msg">Appuyer sur "Scanner" pour détecter les réseaux.</div>
</div>
</div>
</div>
<!-- Formulaire connexion -->
<div>
<div class="wifi-form-titre" style="margin-bottom:10px">Connexion</div>
<div class="wifi-form">
<div class="wifi-field">
<label class="wifi-label" for="wifiSsidInput">SSID</label>
<input class="wifi-input" id="wifiSsidInput" type="text" placeholder="Nom du réseau WiFi" autocomplete="off">
</div>
<div class="wifi-field">
<label class="wifi-label" for="wifiPassInput">Mot de passe</label>
<div class="wifi-pass-wrap">
<input class="wifi-input" id="wifiPassInput" type="password" placeholder="Mot de passe" autocomplete="new-password">
<button class="wifi-pass-toggle" id="btnTogglePass" type="button" title="Afficher/masquer">&#128065;</button>
</div>
</div>
<button class="btn-wifi-save" id="btnWifiSave">Sauvegarder et redémarrer</button>
<div class="wifi-feedback" id="wifiFeedback"></div>
</div>
</div>
</div>
</div>
<!-- ══ SCRIPT ═════════════════════════════════════════════════════════ -->
<script>
(function(){
@@ -831,6 +992,191 @@ function majHorloge(){
document.getElementById('sbClock').textContent='esp_jardin | '+h+':'+m+':'+s;
}
/* ──────────────────────────────────────────────────────────
GESTION WIFI
────────────────────────────────────────────────────────── */
var wifiScanInterval = null;
// Convertit un RSSI en barres Unicode ▁▂▃▄▅
function rssiBarres(rssi) {
if (rssi >= -55) return '▅▅▅▅▅';
if (rssi >= -65) return '▄▄▄▄░';
if (rssi >= -75) return '▃▃▃░░';
if (rssi >= -85) return '▂▂░░░';
return '▁░░░░';
}
function ouvrirModalWifi() {
document.getElementById('wifiModalBackdrop').classList.add('open');
chargerWifiCurrent();
}
function fermerModalWifi() {
document.getElementById('wifiModalBackdrop').classList.remove('open');
stopScanPolling();
}
function chargerWifiCurrent() {
fetch('/api/wifi/current')
.then(function(r){ return r.json(); })
.then(function(d) {
document.getElementById('wifiCurSsid').textContent = d.ssid || '—';
document.getElementById('wifiCurIp').textContent = d.ip || '—';
document.getElementById('wifiCurRssi').textContent = d.rssi ? d.rssi + ' dBm' : '—';
document.getElementById('wifiCurMode').textContent = d.modeAP ? 'Point d\'accès (AP)' : 'Station (STA)';
})
.catch(function() {
document.getElementById('wifiCurSsid').textContent = 'Erreur réseau';
});
}
function stopScanPolling() {
if (wifiScanInterval) {
clearInterval(wifiScanInterval);
wifiScanInterval = null;
}
}
function demarrerScan() {
var btn = document.getElementById('btnScan');
var liste = document.getElementById('wifiNetworksList');
btn.disabled = true;
btn.textContent = '⏳ Scan…';
liste.innerHTML = '<div class="wifi-scan-msg">Scan en cours…</div>';
stopScanPolling();
// Premier appel immédiat
fetch('/api/wifi/networks')
.then(function(r){ return r.json(); })
.then(function(d) { traiterResultatScan(d); })
.catch(function() {
liste.innerHTML = '<div class="wifi-scan-msg" style="color:var(--err)">Erreur réseau.</div>';
btn.disabled = false;
btn.textContent = '⟳ Réessayer';
});
// Polling toutes les 2s
wifiScanInterval = setInterval(function() {
fetch('/api/wifi/networks')
.then(function(r){ return r.json(); })
.then(function(d) { traiterResultatScan(d); })
.catch(function() { stopScanPolling(); });
}, 2000);
}
function traiterResultatScan(d) {
var btn = document.getElementById('btnScan');
var liste = document.getElementById('wifiNetworksList');
if (d.etat === 'scan_en_cours') {
// Continuer le polling
return;
}
// Scan terminé
stopScanPolling();
btn.disabled = false;
btn.textContent = '↻ Rescanner';
if (!d.reseaux || d.reseaux.length === 0) {
liste.innerHTML = '<div class="wifi-scan-msg">Aucun réseau détecté.</div>';
return;
}
// Tri par RSSI décroissant
d.reseaux.sort(function(a, b) { return b.rssi - a.rssi; });
liste.innerHTML = '';
d.reseaux.forEach(function(r) {
var item = document.createElement('div');
item.className = 'wifi-net-item';
item.innerHTML =
'<span class="wifi-net-ssid">' + escHtml(r.ssid) + '</span>' +
'<span class="wifi-net-signal" title="' + r.rssi + ' dBm">' + rssiBarres(r.rssi) + '</span>' +
'<span class="wifi-net-lock" title="' + (r.secure ? 'Sécurisé' : 'Ouvert') + '">' +
(r.secure ? '&#128274;' : '&#128275;') +
'</span>';
item.addEventListener('click', function() {
document.getElementById('wifiSsidInput').value = r.ssid;
document.getElementById('wifiPassInput').value = '';
document.getElementById('wifiPassInput').focus();
});
liste.appendChild(item);
});
}
function escHtml(str) {
return String(str)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
function envoyerWifiConnect() {
var ssid = document.getElementById('wifiSsidInput').value.trim();
var pass = document.getElementById('wifiPassInput').value;
var feedback = document.getElementById('wifiFeedback');
var btnSave = document.getElementById('btnWifiSave');
if (!ssid) {
feedback.style.color = 'var(--err)';
feedback.textContent = 'Le SSID ne peut pas être vide.';
return;
}
btnSave.disabled = true;
feedback.style.color = 'var(--warn)';
feedback.textContent = 'Envoi en cours…';
fetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: pass })
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
feedback.style.color = 'var(--ok)';
feedback.innerHTML =
'Redémarrage en cours… (~5s)<br>' +
'<small style="color:var(--ink-3)">Reconnectez-vous sur <a href="http://esp_jardin.local" style="color:var(--blue)">esp_jardin.local</a></small>';
} else {
feedback.style.color = 'var(--err)';
feedback.textContent = d.erreur || 'Erreur inconnue.';
btnSave.disabled = false;
}
})
.catch(function() {
// L'ESP redémarre et coupe la connexion — c'est normal
feedback.style.color = 'var(--ok)';
feedback.innerHTML =
'Redémarrage en cours… (~5s)<br>' +
'<small style="color:var(--ink-3)">Reconnectez-vous sur <a href="http://esp_jardin.local" style="color:var(--blue)">esp_jardin.local</a></small>';
});
}
// Événements modal WiFi
document.getElementById('btnWifi').addEventListener('click', ouvrirModalWifi);
document.getElementById('btnCloseWifi').addEventListener('click', fermerModalWifi);
document.getElementById('wifiModalBackdrop').addEventListener('click', function(e) {
if (e.target === this) fermerModalWifi();
});
document.getElementById('btnScan').addEventListener('click', demarrerScan);
document.getElementById('btnWifiSave').addEventListener('click', envoyerWifiConnect);
// Bouton afficher/masquer mot de passe
document.getElementById('btnTogglePass').addEventListener('click', function() {
var inp = document.getElementById('wifiPassInput');
if (inp.type === 'password') {
inp.type = 'text';
this.textContent = '🙈';
} else {
inp.type = 'password';
this.textContent = '👁';
}
});
/* ──────────────────────────────────────────────────────────
INITIALISATION
────────────────────────────────────────────────────────── */
+6
View File
@@ -5,3 +5,9 @@ void network_init();
// À appeler à chaque loop() : gère OTA, reconnexion WiFi non-bloquante
void network_update();
// Retourne le SSID actuellement utilisé (NVS ou config.h)
const char* network_get_ssid();
// Sauvegarde les credentials WiFi en NVS (SSID + mot de passe)
void network_save_credentials(const char* ssid, const char* password);
+36 -2
View File
@@ -3,16 +3,34 @@
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <Preferences.h>
static uint32_t _dernierRetryMs = 0;
static uint32_t _debutConnexionMs = 0;
static bool _connexionEnCours = false;
// Credentials actifs (NVS ou config.h)
static char _ssidActif[64] = {};
static char _passActif[128] = {};
static Preferences _prefs;
// Retourne le SSID actuellement utilisé
const char* network_get_ssid() { return _ssidActif; }
// Sauvegarde les credentials WiFi en NVS
void network_save_credentials(const char* ssid, const char* password) {
_prefs.begin("wifi", false);
_prefs.putString("ssid", ssid);
_prefs.putString("pass", password);
_prefs.end();
Serial.printf("[WIFI] Credentials sauvegardés: %s\n", ssid);
}
// Démarre la tentative de connexion STA (non-bloquant)
static void _demarrerSTA() {
Serial.printf("[WIFI] Connexion STA → SSID: %s\n", WIFI_SSID);
Serial.printf("[WIFI] Connexion STA → SSID: %s\n", _ssidActif);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
WiFi.begin(_ssidActif, _passActif);
_debutConnexionMs = millis();
_connexionEnCours = true;
netStatus.modeAP = false; // reset dès la tentative STA
@@ -52,6 +70,22 @@ static void _configurerOTA() {
}
void network_init() {
// Chargement des credentials depuis NVS
_prefs.begin("wifi", true); // true = lecture seule
String savedSsid = _prefs.getString("ssid", "");
String savedPass = _prefs.getString("pass", "");
_prefs.end();
if (savedSsid.length() > 0) {
savedSsid.toCharArray(_ssidActif, sizeof(_ssidActif));
savedPass.toCharArray(_passActif, sizeof(_passActif));
Serial.printf("[WIFI] Credentials NVS: %s\n", _ssidActif);
} else {
strncpy(_ssidActif, WIFI_SSID, sizeof(_ssidActif) - 1);
strncpy(_passActif, WIFI_PASS, sizeof(_passActif) - 1);
Serial.printf("[WIFI] Credentials config.h: %s\n", _ssidActif);
}
_demarrerSTA();
}
+75
View File
@@ -1,8 +1,10 @@
#include "web_server.h"
#include "network.h"
#include "config.h"
#include <LittleFS.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <WiFi.h>
static AsyncWebServer _server(80);
static AsyncWebSocket _ws("/ws");
@@ -133,6 +135,79 @@ void web_server_init() {
}
);
// GET /api/wifi/current — infos réseau actuel
_server.on("/api/wifi/current", HTTP_GET, [](AsyncWebServerRequest* req) {
JsonDocument doc;
if (netStatus.wifiConnecte && !netStatus.modeAP) {
doc["ssid"] = network_get_ssid();
doc["ip"] = WiFi.localIP().toString();
doc["rssi"] = WiFi.RSSI();
doc["modeAP"] = false;
} else {
doc["ssid"] = netStatus.modeAP ? AP_SSID : network_get_ssid();
doc["ip"] = netStatus.modeAP ? WiFi.softAPIP().toString() : "";
doc["rssi"] = 0;
doc["modeAP"] = netStatus.modeAP;
}
String out;
serializeJson(doc, out);
req->send(200, "application/json", out);
});
// GET /api/wifi/networks — scan WiFi asynchrone
static bool _scanDemande = false;
_server.on("/api/wifi/networks", HTTP_GET, [](AsyncWebServerRequest* req) {
int n = WiFi.scanComplete();
if (n == WIFI_SCAN_RUNNING) {
req->send(200, "application/json", "{\"etat\":\"scan_en_cours\"}");
return;
}
if (n == WIFI_SCAN_FAILED || !_scanDemande) {
WiFi.scanNetworks(true); // scan asynchrone non-bloquant
_scanDemande = true;
req->send(200, "application/json", "{\"etat\":\"scan_en_cours\"}");
return;
}
// Scan terminé — construction de la réponse
JsonDocument doc;
doc["etat"] = "ok";
JsonArray arr = doc["reseaux"].to<JsonArray>();
for (int i = 0; i < n; i++) {
JsonObject r = arr.add<JsonObject>();
r["ssid"] = WiFi.SSID(i);
r["rssi"] = WiFi.RSSI(i);
r["secure"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
WiFi.scanDelete();
_scanDemande = false;
String out;
serializeJson(doc, out);
req->send(200, "application/json", out);
});
// POST /api/wifi/connect — sauvegarde credentials NVS et redémarre
_server.on("/api/wifi/connect", HTTP_POST,
[](AsyncWebServerRequest* req) {},
nullptr,
[](AsyncWebServerRequest* req, uint8_t* data, size_t len, size_t, size_t) {
JsonDocument doc;
if (deserializeJson(doc, data, len)) {
req->send(400, "application/json", "{\"erreur\":\"JSON invalide\"}");
return;
}
const char* ssid = doc["ssid"] | "";
const char* pass = doc["password"] | "";
if (strlen(ssid) == 0) {
req->send(400, "application/json", "{\"erreur\":\"SSID vide\"}");
return;
}
network_save_credentials(ssid, pass);
req->send(200, "application/json", "{\"ok\":true}");
delay(500); // exception autorisée : redémarrage immédiat
ESP.restart();
}
);
if (fsOk) {
_server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
}