/*ESP21 Hourglass on 16x16 Matrix WS2812b
by mircemk, April 2025
*/
#include <FastLED.h>
#define LED_PIN 5
#define NUM_LEDS 256
#define BRIGHTNESS 64
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
#define TILT_PIN 4 // D4 pin for tilt switch
#define BUZZER_PIN 2 // Choose an available digital pin for the buzzer
CRGB leds[NUM_LEDS];
// Colors
CRGB BLACK = CRGB(0, 0, 0);
CRGB MAGENTA = CRGB(255, 0, 255);
CRGB YELLOW = CRGB(255, 255, 0);
CRGB WHITE = CRGB(255, 255, 255); // Color for digits
CRGB PALE_PURPLE = CRGB(0, 0, 0); // Very dim purple for outside dots
CRGB PALE_RED = CRGB(7,15, 15); // Very dim red for inside dots
// Animation timing
const unsigned long PARTICLE_FALL_TIME = 2000; // 2 seconds per particle
const int TOTAL_PARTICLES = 30;
const unsigned long RESTART_DELAY = 60000; // 1 minute
// Grid dimensions
const int GRID_WIDTH = 16;
const int GRID_HEIGHT = 16;
// Digit display positions (7th row from top, 3 pixels from edges)
const int LEFT_DIGIT_X = 0; // Changed from 3 to 0 (far left)
const int RIGHT_DIGIT_X = 13; // Changed from 10 to 13 (far right)
const int DIGIT_Y = 6; // Keep the same vertical position
const int START_TONES[] = {300, 600, 900}; // Starting sequence frequencies
const int TICK_TONE = 100; // Countdown tick frequency
const int END_TONES[] = {900, 600, 300}; // Ending sequence frequencies
const int START_END_TONE_DURATION = 200; // Duration for start/end tones in ms
const int TICK_TONE_DURATION = 50; // Duration for tick tone in ms
bool displayRotated = false; // Track if display is rotated
unsigned long lastTiltCheck = 0; // Debouncing
const unsigned long TILT_CHECK_DELAY = 50; // Check tilt every 50ms
unsigned long lastSecond = 60; // Track last second for tone
bool startTonesPlayed = false; // Track if start tones have been played
bool endTonesPlayed = false; // Track if end tones have been played
// Tracking variables
unsigned long startTime = 0;
unsigned long currentTime = 0;
int particlesFallen = 0;
bool animationComplete = false;
// Use a 1D array to track sand (1=sand, 0=no sand)
byte sandState[NUM_LEDS];
// Falling particle
bool fallingParticle = false;
uint8_t fallingParticleX = 0;
uint8_t fallingParticleY = 0;
unsigned long fallingStartTime = 0;
// Convert x,y coordinates to LED index (assuming serpentine layout)
uint16_t XY(uint8_t x, uint8_t y) {
uint16_t i;
if (displayRotated) {
// If rotated, flip both x and y coordinates
x = GRID_WIDTH - 1 - x;
y = GRID_HEIGHT - 1 - y;
}
if(y & 0x01) { // Odd rows run backwards
uint8_t reverseX = (GRID_WIDTH-1) - x;
i = (y * GRID_WIDTH) + reverseX;
} else { // Even rows run forwards
i = (y * GRID_WIDTH) + x;
}
return i;
}
// 5x3 Font Data for digits 0-9 (full 5x3 matrix design)
const byte DIGITS[10][5][3] = {
{ // 0
{1,1,1},
{1,0,1},
{1,0,1},
{1,0,1},
{1,1,1}
},
{ // 1
{0,1,0},
{1,1,0},
{0,1,0},
{0,1,0},
{1,1,1}
},
{ // 2
{1,1,1},
{0,0,1},
{1,1,1},
{1,0,0},
{1,1,1}
},
{ // 3
{1,1,1},
{0,0,1},
{1,1,1},
{0,0,1},
{1,1,1}
},
{ // 4
{1,0,1},
{1,0,1},
{1,1,1},
{0,0,1},
{0,0,1}
},
{ // 5
{1,1,1},
{1,0,0},
{1,1,1},
{0,0,1},
{1,1,1}
},
{ // 6
{1,1,1},
{1,0,0},
{1,1,1},
{1,0,1},
{1,1,1}
},
{ // 7
{1,1,1},
{0,0,1},
{0,1,0},
{1,0,0},
{1,0,0}
},
{ // 8
{1,1,1},
{1,0,1},
{1,1,1},
{1,0,1},
{1,1,1}
},
{ // 9
{1,1,1},
{1,0,1},
{1,1,1},
{0,0,1},
{1,1,1}
}
};
// drawDigit function to handle 5x3 digits
void drawDigit(int digit, int xPos, int yPos, CRGB color) {
for (int y = 0; y < 5; y++) { // Changed from 6 to 5
for (int x = 0; x < 3; x++) {
if (DIGITS[digit][y][x]) {
// Reverse the x-coordinate by drawing from right to left
leds[XY(xPos + (2 - x), yPos + y)] = color;
}
}
}
}
// Function to draw countdown number
void drawCountdown(int seconds) {
int tens = seconds / 10;
int ones = seconds % 10;
// Draw tens digit on the right
drawDigit(tens, RIGHT_DIGIT_X, DIGIT_Y, WHITE);
// Draw ones digit on the left
drawDigit(ones, LEFT_DIGIT_X, DIGIT_Y, WHITE);
}
// Check if a position is within the hourglass container
bool isInsideHourglass(uint8_t x, uint8_t y) {
// Top half
if (y <= 7) {
if (y == 0 && x >= 1 && x <= 14) return true;
if (y >= 1 && y <= 3 && x >= 2 && x <= 13) return true;
if (y == 4 && x >= 3 && x <= 12) return true;
if (y == 5 && x >= 4 && x <= 11) return true;
if (y == 6 && x >= 5 && x <= 10) return true;
if (y == 7 && x >= 6 && x <= 9) return true;
}
// Bottom half
else {
if (y == 8 && x >= 6 && x <= 9) return true;
if (y == 9 && x >= 5 && x <= 10) return true;
if (y == 10 && x >= 4 && x <= 11) return true;
if (y == 11 && x >= 3 && x <= 12) return true;
if (y >= 12 && y <= 14 && x >= 2 && x <= 13) return true;
if (y == 15 && x >= 1 && x <= 14) return true;
}
return false;
}
// Check if a position is part of the hourglass outline
bool isHourglassOutline(uint8_t x, uint8_t y) {
// Top base (row 0)
if (y == 0 && x >= 1 && x <= 14) return true;
// Vertical walls - top half
if (y >= 1 && y <= 3 && (x == 2 || x == 13)) return true;
if (y == 4 && (x == 3 || x == 12)) return true;
if (y == 5 && (x == 4 || x == 11)) return true;
if (y == 6 && (x == 5 || x == 10)) return true;
if (y == 7 && (x == 6 || x == 9)) return true;
// Neck - only the sides, keeping the middle open
if (y == 7 && (x == 7 || x == 8)) return false;
if (y == 8 && (x == 7 || x == 8)) return false;
// Vertical walls - bottom half
if (y == 8 && (x == 6 || x == 9)) return true;
if (y == 9 && (x == 5 || x == 10)) return true;
if (y == 10 && (x == 4 || x == 11)) return true;
if (y == 11 && (x == 3 || x == 12)) return true;
if (y >= 12 && y <= 14 && (x == 2 || x == 13)) return true;
// Bottom base (row 15)
if (y == 15 && x >= 1 && x <= 14) return true;
return false;
}
// Check if a position is in the neck area
bool isNeckPosition(uint8_t x, uint8_t y) {
return ((y == 7 || y == 8) && (x == 7 || x == 8));
}
// Initialize the hourglass with sand particles
// Initialize the hourglass with sand particles
void initHourglass() {
// First, set the background colors instead of clearing to black
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y)) {
// Inside hourglass - pale red background
sandState[XY(x, y)] = 0; // Initialize as empty
leds[XY(x, y)] = PALE_RED;
} else if (!isInsideHourglass(x, y)) {
// Outside hourglass - pale purple background
sandState[XY(x, y)] = 0;
leds[XY(x, y)] = PALE_PURPLE;
} else {
// Areas that will be outline
sandState[XY(x, y)] = 0;
leds[XY(x, y)] = BLACK;
}
}
}
int particleCount = 0;
// First add 2 particles in the upper neck area (y=7)
sandState[XY(7, 7)] = 1; // First neck particle
sandState[XY(8, 7)] = 1; // Second neck particle
particleCount = 2;
// Fill the remaining 28 particles in the top half
for (uint8_t y = 3; y <= 7; y++) {
for (uint8_t x = 0; x < GRID_WIDTH && particleCount < TOTAL_PARTICLES; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y) &&
!(x == 7 && y == 7) && !(x == 8 && y == 7) && // Skip the neck positions we already filled
sandState[XY(x, y)] == 0) {
sandState[XY(x, y)] = 1; // Add sand
particleCount++;
}
}
}
// Draw initial state
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (sandState[XY(x, y)] == 1) {
leds[XY(x, y)] = YELLOW; // Draw sand particles
} else if (isHourglassOutline(x, y)) {
leds[XY(x, y)] = MAGENTA; // Draw outline
}
}
}
FastLED.show(); // Show the initial state
particlesFallen = 0;
animationComplete = false;
}
// Find a sand particle in the top container to drop
bool findSandParticleToRemove(uint8_t* outX, uint8_t* outY) {
for (uint8_t y = 3; y <= 7; y++) {
int particleXPositions[GRID_WIDTH];
int particleYPositions[GRID_WIDTH];
int count = 0;
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y) && sandState[XY(x, y)] == 1) {
particleXPositions[count] = x;
particleYPositions[count] = y;
count++;
}
}
if (count > 0) {
int randomIndex = random(count);
*outX = particleXPositions[randomIndex];
*outY = particleYPositions[randomIndex];
sandState[XY(*outX, *outY)] = 0; // Remove this particle
return true;
}
}
return false;
}
// Find a position in the bottom container
bool findPositionInBottomContainer(uint8_t* outX, uint8_t* outY) {
for (uint8_t y = 15; y >= 9; y--) {
int availableSpots[GRID_WIDTH];
int count = 0;
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y) && sandState[XY(x, y)] == 0) {
availableSpots[count] = x;
count++;
}
}
if (count > 0) {
int randomIndex = random(count);
*outX = availableSpots[randomIndex];
*outY = y;
return true;
}
}
return false;
}
// Start a new falling particle
void startNewFallingParticle() {
uint8_t startX, startY;
if (!findSandParticleToRemove(&startX, &startY)) {
fallingParticle = false;
return;
}
fallingParticleX = startX;
fallingParticleY = startY;
fallingParticle = true;
fallingStartTime = millis();
particlesFallen++;
}
// Update falling particle position
void updateFallingParticle() {
if (!fallingParticle) return;
unsigned long elapsed = millis() - fallingStartTime;
if (elapsed >= PARTICLE_FALL_TIME) {
fallingParticle = false;
uint8_t endX, endY;
if (findPositionInBottomContainer(&endX, &endY)) {
sandState[XY(endX, endY)] = 1;
}
if (particlesFallen < TOTAL_PARTICLES) {
startNewFallingParticle();
} else {
animationComplete = true;
}
return;
}
float progress = (float)elapsed / PARTICLE_FALL_TIME;
uint8_t targetX = (fallingParticleX < 8) ? 7 : 8;
if (progress < 0.5) {
float neckProgress = progress * 2;
fallingParticleX = fallingParticleX + (neckProgress * (targetX - fallingParticleX));
fallingParticleY = fallingParticleY + (neckProgress * (7 - fallingParticleY));
} else {
float bottomProgress = (progress - 0.5) * 2;
fallingParticleX = targetX;
uint8_t endY;
uint8_t endX;
findPositionInBottomContainer(&endX, &endY);
fallingParticleY = 8 + (bottomProgress * (endY - 8));
}
}
void playTone(int frequency, int duration) {
tone(BUZZER_PIN, frequency, duration);
}
void playStartSequence() {
for (int i = 0; i < 3; i++) {
playTone(START_TONES[i], START_END_TONE_DURATION);
delay(START_END_TONE_DURATION);
}
startTonesPlayed = true;
}
void playEndSequence() {
for (int i = 0; i < 3; i++) {
playTone(END_TONES[i], START_END_TONE_DURATION);
delay(START_END_TONE_DURATION);
}
endTonesPlayed = true;
}
void setup() {
delay(1000);
pinMode(BUZZER_PIN, OUTPUT);
pinMode(TILT_PIN, INPUT_PULLUP);
FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS).setCorrection(TypicalLEDStrip);
FastLED.setBrightness(BRIGHTNESS);
// Initial orientation check
displayRotated = !digitalRead(TILT_PIN); // Invert because of pull-up
randomSeed(analogRead(0));
initHourglass();
startTime = millis();
startNewFallingParticle();
startTonesPlayed = false; // Reset start tones flag
endTonesPlayed = false; // Reset end tones flag
}
void loop() {
currentTime = millis();
// Check tilt switch with debouncing
if (currentTime - lastTiltCheck >= TILT_CHECK_DELAY) {
bool newRotation = !digitalRead(TILT_PIN); // Invert because of pull-up
if (newRotation != displayRotated) {
displayRotated = newRotation;
// Reset hourglass when flipped
initHourglass();
startTime = currentTime;
particlesFallen = 0;
animationComplete = false;
startNewFallingParticle();
}
lastTiltCheck = currentTime;
}
// Calculate remaining time
int remainingSeconds = 60;
if (currentTime > startTime) {
unsigned long elapsedTime = currentTime - startTime;
if (elapsedTime < RESTART_DELAY) {
remainingSeconds = (RESTART_DELAY - elapsedTime + 999) / 1000;
} else {
remainingSeconds = 0;
}
}
// Play start sequence if not yet played
if (!startTonesPlayed && remainingSeconds == 60) {
playStartSequence();
}
// Play tick tone when second changes
if (remainingSeconds < lastSecond && remainingSeconds > 0) {
playTone(TICK_TONE, TICK_TONE_DURATION);
}
lastSecond = remainingSeconds;
// Play end sequence when countdown reaches zero
if (remainingSeconds == 0 && !endTonesPlayed && animationComplete) {
playEndSequence();
}
// If animation is complete and time is up, show final state
if (animationComplete && (currentTime - startTime >= RESTART_DELAY)) {
// Draw background colors first
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y)) {
leds[XY(x, y)] = PALE_RED; // Inside hourglass background
} else if (!isInsideHourglass(x, y)) {
leds[XY(x, y)] = PALE_PURPLE; // Outside hourglass background
}
}
}
// Draw final hourglass state
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isHourglassOutline(x, y)) {
leds[XY(x, y)] = MAGENTA;
}
if (sandState[XY(x, y)] == 1) {
leds[XY(x, y)] = YELLOW;
}
}
}
// Draw final 00
drawCountdown(0);
FastLED.show();
delay(50);
// return;
}
// Draw background colors first
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y)) {
leds[XY(x, y)] = PALE_RED; // Inside hourglass background
} else if (!isInsideHourglass(x, y)) {
leds[XY(x, y)] = PALE_PURPLE; // Outside hourglass background
}
}
}
// Draw the hourglass outline
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isHourglassOutline(x, y)) {
leds[XY(x, y)] = MAGENTA;
}
}
}
// Draw sand particles
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (sandState[XY(x, y)] == 1) {
leds[XY(x, y)] = YELLOW;
}
}
}
// Update and draw falling particle
if (!animationComplete) {
updateFallingParticle();
// Draw falling particle
if (fallingParticle) {
leds[XY(fallingParticleX, fallingParticleY)] = YELLOW;
}
}
// Draw countdown numbers
drawCountdown(remainingSeconds);
FastLED.show();
delay(50); // Slow down animation to 20fps
// return;
}
Comments
Please log in or sign up to comment.