Sablier à LED piloter par Arduino Nano
Description :
Ce projet est une réinterprétation moderne du sablier traditionnel. Ici, le temps ne s’écoule plus par le mouvement de grains de sable physiques, mais par la chute de pixels lumineux migrant d’une matrice LED à l’autre.
Pour rendre l’objet aussi interactif que fonctionnel, j’ai implémenté trois modes de contrôle via un bouton unique et un potentiomètre :
Réglage de la vitesse : Grâce au potentiomètre, la durée du sablier est totalement ajustable. Le code convertit la valeur analogique en un délai précis (exprimé en millisecondes), permettant de passer d’un minuteur de cuisine rapide à une horloge d’ambiance lente et apaisante.
Gestion du temps (Lecture/Pause) : Un appui court sur le bouton déclenche ou fige l’écoulement des pixels instantanément. Idéal pour suspendre un décompte en cours.
Réinitialisation (Reset) : Un appui prolongé permet de « retourner » virtuellement le sablier, rechargeant instantanément le réservoir supérieur pour un nouveau cycle.
Prérequis :
- 1 x Carte Arduino Nano
- 1 x Module matriciel LED WS2812 LED 5050 RGB 8×8 64 bits
- 1 x Bouton
- 1 x Potentiomètre 10KΩ
- 1 x Breadboard
Vidéo de démonstration :
NA
Schéma de câblage :


Code Arduino :
#include <Adafruit_NeoPixel.h>
#include <avr/wdt.h>
// --- Configuration Matérielle ---
#define PIN 6 // Broche Data connectée aux LEDs
#define NUMPIXELS 128 // Nombre total de pixels NeoPixel
const int potPin = A7; // Broche du potentiomètre pour régler la vitesse
const int buttonPin = 2; // Broche du bouton (Pause/Reset)
unsigned long buttonPressTime = 0; // Chronomètre pour la durée d'appui sur le bouton
bool isPressing = false; // État de pression du bouton
// Initialisation de l'objet strip pour piloter les LEDs
Adafruit_NeoPixel strip(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);
// --- Tableaux d'état des Grilles ---
// grid1 : Grille du haut (stockage du sable au départ)
// 9 = Mur, 2 = Sable, 1 = Air, 0 = Hors limites
byte grid1[15][10] = {
/* */ { 9, 2, 9 },
/* */ { 9, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 2, 9 },
/* */ { 9, 2, 2, 9 },
/* */ { 9, 2, 9 }
};
byte grid2[15][10] = {
/* */ { 9, 1, 9 },
/* */ { 9, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 1, 9 },
/* */ { 9, 1, 1, 9 },
/* */ { 9, 1, 9 }
};
const byte Numstrip1[15][10] = {
/* */ { 255, 127, 255 },
/* */ { 255, 126, 119, 255 },
/* */ { 255, 125, 118, 111, 255 },
/* */ { 255, 124, 117, 110, 103, 255 },
/* */ { 255, 123, 116, 109, 102, 95, 255 },
/* */ { 255, 122, 115, 108, 101, 94, 87, 255 },
/* */ { 255, 121, 114, 107, 100, 93, 86, 79, 255 },
/* */ { 255, 120, 113, 106, 99, 92, 85, 78, 71, 255 },
/* */ { 255, 112, 105, 98, 91, 84, 77, 70, 255 },
/* */ { 255, 104, 97, 90, 83, 76, 69, 255 },
/* */ { 255, 96, 89, 82, 75, 68, 255 },
/* */ { 255, 88, 81, 74, 67, 255 },
/* */ { 255, 80, 73, 66, 255 },
/* */ { 255, 72, 65, 255 },
/* */ { 255, 64, 255 }
};
const byte Numstrip2[15][10] = {
/* */ { 255, 63, 255 },
/* */ { 255, 62, 55, 255 },
/* */ { 255, 61, 54, 47, 255 },
/* */ { 255, 60, 53, 46, 39, 255 },
/* */ { 255, 59, 52, 45, 38, 31, 255 },
/* */ { 255, 58, 51, 44, 37, 30, 23, 255 },
/* */ { 255, 57, 50, 43, 36, 29, 22, 15, 255 },
/* */ { 255, 56, 49, 42, 35, 28, 21, 14, 7, 255 },
/* */ { 255, 48, 41, 34, 27, 20, 13, 6, 255 },
/* */ { 255, 40, 33, 26, 19, 12, 5, 255 },
/* */ { 255, 32, 25, 18, 11, 4, 255 },
/* */ { 255, 24, 17, 10, 3, 255 },
/* */ { 255, 16, 9, 2, 255 },
/* */ { 255, 8, 1, 255 },
/* */ { 255, 0, 255 }
};
// --- Index de trajectoires pour l'animation de chute ---
const byte index1[] = { 2, 4, 6, 8, 10, 12, 14 };
const byte index2[] = { 2, 3, 4, 4, 3, 2, 1 };
const byte index3[] = { 1, 2, 3, 4, 4, 3, 2 };
const byte index4[] = { 1, 2, 3, 4, 3, 2, 1 };
const byte index5[] = { 4, 5, 6, 5, 4, 3, 2 };
const byte index6[] = { 1, 2, 3, 2, 1, 1, 1 };
const byte index7[] = { 5, 6, 6, 5, 4, 3, 2 };
const byte index8[] = { 6, 7, 6, 5, 4, 3, 2 };
const byte index9[] = { 7, 7, 6, 5, 4, 3 };
const byte index10[] = { 8, 7, 6, 5, 4 };
const byte index11[] = { 1, 2, 2, 1 };
const byte index12[] = { 1, 2, 1 };
const byte index13[] = { 1, 1 };
// --- Variables globales de gestion ---
int time = 50; // Délai de rafraîchissement
int randN; // Nombre aléatoire pour la direction du grain
int PosTestPos[15]; // Liste des colonnes contenant du sable sur une ligne donnée
int randNb; // Index aléatoire choisi dans la liste des grains
int NbdeTestPos = 0; // Compteur de grains trouvés sur une ligne
int LigneTester = 0; // Index de la ligne actuellement vérifiée dans grid1
bool Pause = 1; // État du programme (1 = Arrêté au départ)
int valeurPot = 0; // Valeur brute du potentiomètre
int valeurMap; // Valeur convertie pour le délai
bool Reset = 1; // Drapeau pour désactiver l'attente pendant un reset
void setup() {
Serial.begin(9600);
pinMode(buttonPin, INPUT_PULLUP); // Utilisation de la résistance interne
pinMode(potPin, INPUT);
strip.begin();
strip.setBrightness(60);
strip.clear();
showleds1();
while (digitalRead(buttonPin) == LOW) {
Pause = 0;
}
}
void loop() {
// Lecture et conversion du potentiomètre pour la vitesse (100ms à 1000ms)
valeurPot = analogRead(potPin);
valeurMap = map(valeurPot, 0, 1023, 100, 1000);
time = valeurMap;
Gestionbt(); // Vérification de l'état du bouton
randN = random(0, 2); // Détermine aléatoirement si un grain glisse à gauche ou à droite
// Si le jeu n'est pas en pause
if (Pause == 0) {
// Analyse de la ligne "LigneTester" pour trouver du sable (valeur 2)
for (int a = 0; a < 10; a++) {
if (grid1[LigneTester][a] == 2) {
PosTestPos[NbdeTestPos] = a; // Mémorise la colonne
NbdeTestPos++;
}
}
// Si on a trouvé au moins un grain de sable sur cette ligne
if (NbdeTestPos > 0) {
randNb = random(0, NbdeTestPos); // On en choisit un au hasard
grid1[LigneTester][PosTestPos[randNb]] = 1; // On retire le grain (devient Air)
NbdeTestPos = 0;
grid2[0][1] = 2; // Le grain est injecté en haut de la grille basse
showleds1(); // Mise à jour visuelle
AnimBAS(); // Déplacement du grain à travers la grille basse
} else {
// Si la ligne est vide, on passe à la ligne suivante
LigneTester++;
if (LigneTester >= 15) {
LigneTester = 0;
}
}
}
}
// Gère la chute verticale du grain dans la grille basse (grid2)
void AnimBAS() {
for (int a = 0; a <= 6; a++) {
// Si la case cible en dessous est vide
if (grid2[index1[a]][index2[a]] == 1) {
grid2[index1[a] - 2][index3[a]] = 1; // Efface la position précédente
grid2[index1[a]][index2[a]] = 2; // Place le sable
showleds1();
}
// Si deux cases latérales sont libres en dessous, choix aléatoire
else if (grid2[index1[a] - 1][index4[a]] == 1 && grid2[index1[a] - 1][index4[a] + 1] == 1) {
grid2[index1[a] - 2][index3[a]] = 1;
if (randN == 1) {
grid2[index1[a] - 1][index4[a]] = 2;
showleds1();
boucle1(a); // Continue le glissement à gauche
} else {
grid2[index1[a] - 1][index4[a] + 1] = 2;
showleds1();
boucle2(a); // Continue le glissement à droite
}
}
// Si seule la case de gauche est libre
else if (grid2[index1[a] - 1][index4[a]] == 1) {
grid2[index1[a] - 2][index3[a]] = 1;
grid2[index1[a] - 1][index4[a]] = 2;
showleds1();
boucle1(a);
}
// Si seule la case de droite est libre
else if (grid2[index1[a] - 1][index4[a] + 1] == 1) {
grid2[index1[a] - 2][index3[a]] = 1;
grid2[index1[a] - 1][index4[a] + 1] = 2;
showleds1();
boucle2(a);
}
}
}
// Gère les rebonds et glissements successifs vers la gauche
void boucle1(int a) {
if (grid2[index1[a]][index2[a] - 1] == 1) {
grid2[index1[a] - 1][index4[a]] = 1;
grid2[index1[a]][index2[a] - 1] = 2;
showleds1();
// Cascade de vérifications pour les niveaux inférieurs
if (grid2[index1[a] + 1][index6[a]] == 1) {
grid2[index1[a]][index2[a] - 1] = 1;
grid2[index1[a] + 1][index6[a]] = 2;
showleds1();
if (grid2[index1[a] + 2][index11[a]] == 1) {
grid2[index1[a] + 1][index6[a]] = 1;
grid2[index1[a] + 2][index11[a]] = 2;
showleds1();
if (grid2[index1[a] + 3][index12[a]] == 1) {
grid2[index1[a] + 2][index11[a]] = 1;
grid2[index1[a] + 3][index12[a]] = 2;
showleds1();
if (grid2[index1[a] + 4][index13[a]] == 1) {
grid2[index1[a] + 3][index12[a]] = 1;
grid2[index1[a] + 4][index13[a]] = 2;
showleds1();
if (grid2[index1[a] + 5][index13[a]] == 1) {
grid2[index1[a] + 4][index13[a]] = 1;
grid2[index1[a] + 5][index13[a]] = 2;
showleds1();
}
}
}
}
}
}
}
// Gère les rebonds et glissements successifs vers la droite
void boucle2(int a) {
if (grid2[index1[a]][index2[a] + 1] == 1) {
grid2[index1[a] - 1][index4[a] + 1] = 1;
grid2[index1[a]][index2[a] + 1] = 2;
showleds1();
if (grid2[index1[a] + 1][index5[a]] == 1) {
grid2[index1[a]][index2[a] + 1] = 1;
grid2[index1[a] + 1][index5[a]] = 2;
showleds1();
if (grid2[index1[a] + 2][index7[a]] == 1) {
grid2[index1[a] + 1][index5[a]] = 1;
grid2[index1[a] + 2][index7[a]] = 2;
showleds1();
if (grid2[index1[a] + 3][index8[a]] == 1) {
grid2[index1[a] + 2][index7[a]] = 1;
grid2[index1[a] + 3][index8[a]] = 2;
showleds1();
if (grid2[index1[a] + 4][index9[a]] == 1) {
grid2[index1[a] + 3][index8[a]] = 1;
grid2[index1[a] + 4][index9[a]] = 2;
showleds1();
if (grid2[index1[a] + 5][index10[a]] == 1) {
grid2[index1[a] + 4][index9[a]] = 1;
grid2[index1[a] + 5][index10[a]] = 2;
showleds1();
}
}
}
}
}
}
}
// Met à jour l'affichage physique des LEDs selon l'état des grilles
void showleds1() {
for (int a = 0; a <= 14; a++) {
for (int b = 0; b <= 9; b++) {
// Grille 1 (Haut)
if (grid1[a][b] == 1) strip.setPixelColor(Numstrip1[a][b], strip.Color(0, 0, 0)); // Éteint si Air
if (grid1[a][b] == 2) strip.setPixelColor(Numstrip1[a][b], strip.Color(255, 0, 0)); // Rouge si Sable
// Grille 2 (Bas)
if (grid2[a][b] == 1) strip.setPixelColor(Numstrip2[a][b], strip.Color(0, 0, 0)); // Éteint si Air
if (grid2[a][b] == 2) strip.setPixelColor(Numstrip2[a][b], strip.Color(255, 0, 0)); // Rouge si Sable
}
}
strip.show(); // Affiche réellement sur le ruban LED
attente(time); // Délai basé sur le potentiomètre
}
// Délai personnalisé qui permet de scanner le bouton pendant l'attente
void attente(unsigned long duree) {
unsigned long debutAttente = millis();
while (millis() - debutAttente < duree) {
Gestionbt();
}
}
// Logique de lecture du bouton : clic court (Pause) et clic long (Reset)
void Gestionbt() {
if (digitalRead(buttonPin) == LOW) { // Bouton enfoncé
if (!isPressing) {
isPressing = true;
buttonPressTime = millis();
}
// Si maintenu plus de 3 secondes -> Déclenche le RESET
if (millis() - buttonPressTime >= 3000) {
resetGrids();
}
} else { // Bouton relâché
if (isPressing) {
// Si relâché avant 3s -> Bascule Pause/Play
if (millis() - buttonPressTime < 3000) {
Pause = !Pause;
Serial.println(Pause ? "PAUSE" : "PLAY");
}
isPressing = false;
}
}
}
// Remplit à nouveau la grille du haut et vide celle du bas
void resetGrids() {
wdt_enable(WDTO_15MS); // Active le timer pour un reset dans 15ms
while (1)
; // Attend le crash forcé
}
