J’ai connecté mon site web à une LED physique avec un ESP32 (et c’est devenu addictif)
L’autre jour, ma femme me demande de vider les tiroirs de la commode du salon. On a un tiroir un peu “fourre-tout” avec des vieilles clés, des cartes de fidélité, des pins… bref, le tiroir que j’ouvre à chaque fois pour y fourrer un truc en me disant : “je ne sais pas où le mettre, en attendant il va là…”.
Et là, je tombe sur une enveloppe… et dedans, un petit ESP32. Oui, je m’en souviens, c’était il y a quelques mois, j’avais envie de faire un petit tutoriel avec, mais je ne sais plus du tout quoi… bref… pas grave, je le prends et le dépose sur mon bureau.
On en vient à aujourd’hui : petite journée entre mecs avec mon fils pour une sortie cinéma. Je l’invite à voir le dernier Super Mario (et on s’est éclatés !). On rentre, madame est sortie avec ma fille… ok, mon fils est KO, direction le lit. Et là… je me demande ce que je vais bien pouvoir faire.
Je m’installe à mon bureau et je vois cet ESP32… je le branche, et là, une LED RGB qui enchaîne les couleurs. Et je me dis : “Pourquoi ne pas faire un système de notification que je pourrais poser sur mon bureau, par exemple pour afficher le nombre de visiteurs en temps réel ?”
Et voilà, je commence à me documenter. En fait, ce n’est pas si compliqué… dès qu’on s’y applique.
Le matériel
- 1x ESP32-S3 (avec LED RGB intégrée)
- 1x câble USB
- 0 capteur
- 0 module externe
La LED RGB intégrée suffit.
C’est probablement le projet IoT le plus minimaliste qu’on puisse faire… avec un résultat très satisfaisant.
Le backend : une API toute simple
Côté serveur, j’ai ajouté un endpoint accessible ici : https://danux.be/api/online
{
"online": 1,
"totalArticles": 129
}
Deux infos suffisent :
online→ nombre de visiteurs actifs (heartbeat côté frontend)totalArticles→ permet de détecter une nouvelle publication
Pas besoin de Google Analytics, pas besoin d’usine à gaz. Juste un compteur propre côté serveur.
Que fait l’ESP32 ?
L’ESP32 fait trois choses :
- se connecter au Wi-Fi
- appeler l’API toutes les 10 secondes
- traduire les données en comportement lumineux
Un langage lumineux
Au lieu d’un simple clignotement, j’ai construit une logique visuelle :
🟢 Trafic faible
LED verte avec respiration lente
🟠 Trafic moyen
LED orange, respiration plus rapide
🔴 Trafic élevé
LED rouge, plus intense
⚡ Pic de trafic
Flash rapide orange/rouge pendant 2 secondes
🟣 Record atteint
La LED prend une teinte violette temporaire
🌙 Mode nuit
Luminosité réduite automatiquement à 10% entre 22h et 7h
Le détail qui change tout : l’animation “nouvel article”
OK, je sais que si un article est publié, je suis supposé être au courant… mais quand même, cette fonction est classe et je peux me la péter devant les copains 😄
Quand un nouvel article est publié, la LED ne clignote pas. Elle raconte quelque chose :
- violet quasi invisible
- montée progressive pendant 10 secondes
- accélération lumineuse
- flash blanc
- fade-out élégant
C’est discret… mais impossible à rater.
Si tu as aussi un ESP32 en rab
Si tu veux reproduire ce projet, tout est faisable en quelques minutes avec un ESP32-S3.
Voici le code :
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Adafruit_NeoPixel.h>
#include <time.h>
#include <math.h>
const char* ssid = "Home";
const char* password = "Samuel@51019";
const char* apiUrl = "https://danux.be/api/online";
#define LED_PIN 48
#define NUMPIXELS 1
Adafruit_NeoPixel pixel(NUMPIXELS, LED_PIN, NEO_GRB + NEO_KHZ800);
unsigned long previousApiMillis = 0;
const unsigned long API_INTERVAL = 10000; // 10 secondes
int onlineVisitors = 0;
int previousVisitors = 0;
int totalArticles = 0;
int previousTotalArticles = 0;
int peakVisitors = 0;
bool peakAlertActive = false;
unsigned long peakAlertUntil = 0;
bool newArticleAnimationActive = false;
bool newArticleFlashPhase = false;
unsigned long newArticlePhaseStart = 0;
// Luminosité globale
const int DAY_BRIGHTNESS = 51; // ~20%
const int NIGHT_BRIGHTNESS = 26; // ~10%
void setPixel(uint8_t r, uint8_t g, uint8_t b) {
pixel.setPixelColor(0, pixel.Color(r, g, b));
pixel.show();
}
void clearPixel() {
setPixel(0, 0, 0);
}
void connectWifi() {
Serial.print("Connexion Wi-Fi");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("Wi-Fi connecté");
Serial.print("IP locale: ");
Serial.println(WiFi.localIP());
}
void initTime() {
// Heure Belgique avec gestion auto été/hiver
configTzTime("CET-1CEST,M3.5.0/2,M10.5.0/3", "pool.ntp.org", "time.nist.gov");
struct tm timeinfo;
for (int i = 0; i < 10; i++) {
if (getLocalTime(&timeinfo)) {
Serial.println("Heure NTP synchronisée");
return;
}
delay(500);
}
Serial.println("Impossible de synchroniser l'heure NTP");
}
void setBrightnessForHour() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
pixel.setBrightness(DAY_BRIGHTNESS);
return;
}
int hour = timeinfo.tm_hour;
if (hour >= 22 || hour < 7) {
pixel.setBrightness(NIGHT_BRIGHTNESS); // 10%
} else {
pixel.setBrightness(DAY_BRIGHTNESS); // 20%
}
}
void getTrafficColor(int visitors, uint8_t &r, uint8_t &g, uint8_t &b) {
if (visitors <= 0) {
r = 0; g = 0; b = 0;
} else if (visitors <= 3) {
r = 0; g = 255; b = 0; // vert
} else if (visitors <= 8) {
r = 255; g = 120; b = 0; // orange
} else {
r = 255; g = 0; b = 0; // rouge
}
}
void startPeakAlert() {
peakAlertActive = true;
peakAlertUntil = millis() + 2000; // 2 secondes
}
void startNewArticleAnimation() {
newArticleAnimationActive = true;
newArticleFlashPhase = false;
newArticlePhaseStart = millis();
}
void renderPeakAlert() {
static bool state = false;
static unsigned long lastToggle = 0;
unsigned long now = millis();
if (now - lastToggle >= 120) {
lastToggle = now;
state = !state;
if (state) {
setPixel(255, 40, 0); // orange/rouge
} else {
clearPixel();
}
}
if (now >= peakAlertUntil) {
peakAlertActive = false;
}
}
void renderNewArticleAnimation() {
unsigned long now = millis();
const unsigned long purpleRiseDuration = 10000; // 10 secondes
const unsigned long whiteHoldDuration = 120; // flash blanc plein
const unsigned long whiteFadeDuration = 1200; // fade-out élégant
// Phase 1 : montée violette premium
if (!newArticleFlashPhase) {
unsigned long elapsed = now - newArticlePhaseStart;
if (elapsed >= purpleRiseDuration) {
newArticleFlashPhase = true;
newArticlePhaseStart = now;
setPixel(255, 255, 255);
return;
}
float progress = (float)elapsed / purpleRiseDuration;
// montée premium : douce au début, plus nerveuse à la fin
float intensity = 0.01f + (pow(progress, 2.2f) * 0.99f);
uint8_t r = (uint8_t)(180 * intensity);
uint8_t g = 0;
uint8_t b = (uint8_t)(255 * intensity);
setPixel(r, g, b);
return;
}
// Phase 2 : blanc puis fade-out
unsigned long elapsed = now - newArticlePhaseStart;
if (elapsed < whiteHoldDuration) {
setPixel(255, 255, 255);
return;
}
unsigned long fadeElapsed = elapsed - whiteHoldDuration;
if (fadeElapsed >= whiteFadeDuration) {
clearPixel();
newArticleAnimationActive = false;
newArticleFlashPhase = false;
return;
}
float fadeProgress = (float)fadeElapsed / whiteFadeDuration;
float intensity = pow(1.0f - fadeProgress, 2.0f);
uint8_t white = (uint8_t)(255 * intensity);
setPixel(white, white, white);
}
void renderBreathing() {
uint8_t r, g, b;
getTrafficColor(onlineVisitors, r, g, b);
if (onlineVisitors <= 0) {
clearPixel();
return;
}
float t = millis() / 1000.0f;
float speed = 1.2f + min(onlineVisitors, 10) * 0.08f;
float wave = (sin(t * speed * 2.0f * PI) + 1.0f) / 2.0f;
float intensity = 0.15f + wave * 0.85f;
// Si on est au record local, on ajoute une teinte violette
if (onlineVisitors >= peakVisitors && peakVisitors > 0) {
uint8_t vr = 180, vg = 0, vb = 255;
r = (r + vr) / 2;
g = (g + vg) / 2;
b = (b + vb) / 2;
}
setPixel((uint8_t)(r * intensity), (uint8_t)(g * intensity), (uint8_t)(b * intensity));
}
bool fetchStatus() {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi non connecté");
return false;
}
WiFiClientSecure client;
client.setInsecure(); // suffisant pour usage perso/test
HTTPClient http;
if (!http.begin(client, apiUrl)) {
Serial.println("Impossible d'initialiser HTTP");
return false;
}
int httpCode = http.GET();
if (httpCode <= 0) {
Serial.print("Erreur HTTP: ");
Serial.println(http.errorToString(httpCode));
http.end();
return false;
}
String payload = http.getString();
http.end();
Serial.print("HTTP ");
Serial.println(httpCode);
Serial.print("Payload: ");
Serial.println(payload);
StaticJsonDocument<256> doc;
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("Erreur JSON: ");
Serial.println(error.c_str());
return false;
}
previousVisitors = onlineVisitors;
previousTotalArticles = totalArticles;
if (doc["online"].is<int>()) {
onlineVisitors = doc["online"].as<int>();
}
if (doc["totalArticles"].is<int>()) {
totalArticles = doc["totalArticles"].as<int>();
}
// Alerte pic de trafic
if (onlineVisitors >= previousVisitors + 3) {
startPeakAlert();
}
// Nouveau record local
if (onlineVisitors > peakVisitors) {
peakVisitors = onlineVisitors;
startPeakAlert();
}
// Nouvel article publié
if (previousTotalArticles > 0 && totalArticles > previousTotalArticles) {
startNewArticleAnimation();
}
Serial.print("Visiteurs en ligne: ");
Serial.println(onlineVisitors);
Serial.print("Record local: ");
Serial.println(peakVisitors);
Serial.print("Articles: ");
Serial.println(totalArticles);
return true;
}
void setup() {
Serial.begin(115200);
delay(1000);
pixel.begin();
pixel.setBrightness(DAY_BRIGHTNESS);
clearPixel();
connectWifi();
initTime();
setBrightnessForHour();
fetchStatus();
if (onlineVisitors > peakVisitors) {
peakVisitors = onlineVisitors;
}
}
void loop() {
unsigned long now = millis();
setBrightnessForHour();
if (now - previousApiMillis >= API_INTERVAL) {
previousApiMillis = now;
fetchStatus();
}
if (newArticleAnimationActive) {
renderNewArticleAnimation();
return;
}
if (peakAlertActive) {
renderPeakAlert();
return;
}
renderBreathing();
}
Si ce projet t’a plu, n’hésite pas à me donner ton avis et à partager, ça fait toujours plaisir ! Et si tu en veux d’autres, je vais essayer d’être plus régulier sur ce genre de tutoriels 😉


