DIY ESP8266 Animated Hourglass on Oled display
A visually and functionally effective beginner project that only required three components to build.
Components and supplies
1
1,3" OLED Display SH1106 128x64 I2C
1
NodeMCU ESP8266
1
Tilt Switch, SPST
Tools and machines
1
Soldering kit
Apps and platforms
1
Arduino IDE
Project description
Code
Code hourglass
cpp
..
1#include <Arduino.h> 2#include <U8g2lib.h> 3#include <Wire.h> 4#include <algorithm> // Add this for std::min 5 6// Define GPIO pin 7#define GPIO_PIN 13 8 9// Initialize U8G2 display - rotation will be set in setup 10U8G2_SH1106_128X64_NONAME_F_HW_I2C display(U8G2_R1); // Default rotation 11 12 13 14// Animation parameters 15const uint8_t SAND_PARTICLES = 25; 16const uint8_t ANIMATION_DELAY = 50; 17const unsigned long HOURGLASS_DURATION = 60000; // 1 minute 18const uint8_t NUM_FALLING_PARTICLES = 8; 19const uint8_t PARTICLE_SPEED_MIN = 1; 20const uint8_t PARTICLE_SPEED_MAX = 2; 21 22// Hourglass dimensions 23const uint8_t GLASS_WIDTH = 50; 24const uint8_t GLASS_HEIGHT = 100; 25const uint8_t GLASS_X = (64 - GLASS_WIDTH) / 2; 26const uint8_t GLASS_Y = 14; 27const uint8_t WALL_THICKNESS = 2; 28const uint8_t TOP_THICKNESS = 5; 29const uint8_t BASE_PROTRUSION = 2; 30const uint8_t NECK_WIDTH = 2; 31const uint8_t NECK_TOTAL = NECK_WIDTH + (WALL_THICKNESS * 2); 32const uint8_t CURVE_STEPS = 15; 33const uint8_t TOP_FILL_PERCENT = 60; 34const uint8_t BOTTOM_FILL_PERCENT = 50; 35const uint8_t DOME_MAX_HEIGHT = 15; // Maximum height of the initial dome 36const uint8_t SPREAD_THRESHOLD = 8; // Height at which sand starts to spread more 37const float DOME_CURVE_FACTOR = 0.7; // Controls dome roundness (0.5-1.0) 38 39uint32_t topPixelCount = 0; // Using uint32_t for larger numbers 40uint32_t bottomPixelCount = 0; // Using uint32_t for larger numbers 41 42 43int calculateDomeHeight(int distanceFromCenter, int maxHeight) { 44 float normalizedDist = (float)distanceFromCenter / (GLASS_WIDTH / 2); 45 return maxHeight * (1 - pow(normalizedDist, DOME_CURVE_FACTOR)); 46} 47 48// Structures for particles 49struct Particle { 50 int8_t x; 51 int8_t y; 52 int8_t velocity; 53 bool active; 54}; 55 56struct FallingParticle { 57 int8_t x; 58 int8_t y; 59 int8_t speed; 60 bool active; 61}; 62 63// Global variables 64Particle particles[SAND_PARTICLES]; 65FallingParticle fallingParticles[NUM_FALLING_PARTICLES]; 66unsigned long startTime; 67bool isRunning = true; 68uint8_t topFillPercent = TOP_FILL_PERCENT; 69uint8_t bottomFillPercent = 0; 70int16_t leftBoundary[GLASS_HEIGHT]; 71int16_t rightBoundary[GLASS_HEIGHT]; 72 73// Function declarations 74void calculateBoundaries(); 75void initializeFallingParticles(); 76void bezierPoint(float t, int x0, int y0, int x1, int y1, int x2, int y2, float &outX, float &outY); 77void drawTopBase(bool isTop); 78void drawHourglass(); 79void updateFallingParticles(); 80void drawFallingParticles(); 81void updateSandLevels(); 82void drawTopSand(); 83void drawBottomSand(); 84void drawSand(); 85 86void checkGPIOAndRotation() { 87 static bool lastPinState = HIGH; 88 bool currentPinState = digitalRead(GPIO_PIN); 89 90 if (currentPinState != lastPinState) { 91 // Pin state changed 92 display.setDisplayRotation(currentPinState ? U8G2_R3 : U8G2_R1); 93 94 // Restart animation 95 startTime = millis(); 96 topFillPercent = TOP_FILL_PERCENT; 97 bottomFillPercent = 0; 98 initializeFallingParticles(); 99 100 lastPinState = currentPinState; 101 } 102} 103 104// Bezier curve calculation function 105void bezierPoint(float t, int x0, int y0, int x1, int y1, int x2, int y2, float &outX, float &outY) { 106 float mt = 1 - t; 107 outX = mt * mt * x0 + 2 * mt * t * x1 + t * t * x2; 108 outY = mt * mt * y0 + 2 * mt * t * y1 + t * t * y2; 109} 110 111// Calculate the boundaries of the hourglass 112void calculateBoundaries() { 113 // ... (No changes in this function) 114 int middleY = GLASS_Y + GLASS_HEIGHT / 2; 115 for (int y = 0; y < GLASS_HEIGHT; y++) { 116 float t; 117 float xL, yL, xR, yR; 118 119 if (y < GLASS_HEIGHT / 2) { // Top half 120 t = (float)(y) / (GLASS_HEIGHT / 2); 121 bezierPoint(t, 122 GLASS_X + WALL_THICKNESS - BASE_PROTRUSION, GLASS_Y, 123 GLASS_X + WALL_THICKNESS, GLASS_Y + GLASS_HEIGHT / 3, 124 GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + WALL_THICKNESS, middleY, 125 xL, yL); 126 bezierPoint(t, 127 GLASS_X + GLASS_WIDTH - WALL_THICKNESS + BASE_PROTRUSION, GLASS_Y, 128 GLASS_X + GLASS_WIDTH - WALL_THICKNESS, GLASS_Y + GLASS_HEIGHT / 3, 129 GLASS_X + GLASS_WIDTH - (GLASS_WIDTH - NECK_TOTAL) / 2 - WALL_THICKNESS, middleY, 130 xR, yR); 131 } else { // Bottom half 132 t = (float)(y - GLASS_HEIGHT / 2) / (GLASS_HEIGHT / 2); 133 bezierPoint(t, 134 GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + WALL_THICKNESS, middleY, 135 GLASS_X + WALL_THICKNESS, GLASS_Y + GLASS_HEIGHT - GLASS_HEIGHT / 3, 136 GLASS_X + WALL_THICKNESS - BASE_PROTRUSION, GLASS_Y + GLASS_HEIGHT, 137 xL, yL); 138 bezierPoint(t, 139 GLASS_X + GLASS_WIDTH - (GLASS_WIDTH - NECK_TOTAL) / 2 - WALL_THICKNESS, middleY, 140 GLASS_X + GLASS_WIDTH - WALL_THICKNESS, GLASS_Y + GLASS_HEIGHT - GLASS_HEIGHT / 3, 141 GLASS_X + GLASS_WIDTH - WALL_THICKNESS + BASE_PROTRUSION, GLASS_Y + GLASS_HEIGHT, 142 xR, yR); 143 } 144 145 leftBoundary[y] = round(xL) + 1; 146 rightBoundary[y] = round(xR) - 1; 147 } 148} 149 150// Draw top or bottom base of the hourglass 151void drawTopBase(bool isTop) { 152 // ... (No changes in this function) 153 int yPos = isTop ? GLASS_Y : GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS; 154 int xExtension = 6; // Amount to extend beyond glass width on EACH side 155 156 // Original glass edges 157 int glassStartX = GLASS_X; 158 int glassEndX = GLASS_X + GLASS_WIDTH; 159 160 // Base edges (extending beyond glass) 161 int baseStartX = glassStartX - xExtension; 162 int baseEndX = glassEndX + xExtension; 163 164 // Draw main rectangle without corners 165 for (int x = baseStartX + 2; x <= baseEndX - 2; x++) { 166 display.drawPixel(x, yPos); // Top line 167 display.drawPixel(x, yPos + TOP_THICKNESS - 1); // Bottom line 168 } 169 170 // Draw vertical sides without top and bottom pixels 171 for (int y = yPos + 1; y < yPos + TOP_THICKNESS - 1; y++) { 172 display.drawPixel(baseStartX, y); // Left side 173 display.drawPixel(baseEndX, y); // Right side 174 } 175 176 // Draw rounded corners 177 // Top-left corner 178 display.drawPixel(baseStartX + 1, yPos); 179 display.drawPixel(baseStartX + 1, yPos + 1); 180 display.drawPixel(baseStartX, yPos + 1); 181 182 // Top-right corner 183 display.drawPixel(baseEndX - 1, yPos); 184 display.drawPixel(baseEndX - 1, yPos + 1); 185 display.drawPixel(baseEndX, yPos + 1); 186 187 // Bottom-left corner 188 display.drawPixel(baseStartX + 1, yPos + TOP_THICKNESS - 1); 189 display.drawPixel(baseStartX + 1, yPos + TOP_THICKNESS - 2); 190 display.drawPixel(baseStartX, yPos + TOP_THICKNESS - 2); 191 192 // Bottom-right corner 193 display.drawPixel(baseEndX - 1, yPos + TOP_THICKNESS - 1); 194 display.drawPixel(baseEndX - 1, yPos + TOP_THICKNESS - 2); 195 display.drawPixel(baseEndX, yPos + TOP_THICKNESS - 2); 196 197 // Fill the base 198 for (int x = baseStartX + 1; x < baseEndX; x++) { 199 for (int y = yPos + 1; y < yPos + TOP_THICKNESS - 1; y++) { 200 // display.drawPixel(x, y); 201 } 202 } 203} 204// Initialize falling particles 205void initializeFallingParticles() { 206 for (uint8_t i = 0; i < NUM_FALLING_PARTICLES; i++) { 207 fallingParticles[i].active = false; 208 fallingParticles[i].x = 0; 209 fallingParticles[i].y = 0; 210 fallingParticles[i].speed = 0; 211 } 212} 213 214void updateFallingParticles() { 215 int middleY = GLASS_Y + GLASS_HEIGHT / 2; 216 int neckLeft = GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + WALL_THICKNESS + 1; 217 int neckWidth = NECK_WIDTH - 2; 218 int bottomLimit = GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS - (bottomFillPercent * GLASS_HEIGHT / 200); 219 220 // Activate new particles 221 for (uint8_t i = 0; i < NUM_FALLING_PARTICLES; i++) { 222 if (!fallingParticles[i].active && random(100) < 30 && topFillPercent > 0) { 223 fallingParticles[i].active = true; 224 fallingParticles[i].x = neckLeft + random(neckWidth); 225 fallingParticles[i].y = middleY; 226 fallingParticles[i].speed = random(PARTICLE_SPEED_MIN, PARTICLE_SPEED_MAX + 1); 227 } 228 } 229 230 // Update active particles 231 for (uint8_t i = 0; i < NUM_FALLING_PARTICLES; i++) { 232 if (fallingParticles[i].active) { 233 fallingParticles[i].y += fallingParticles[i].speed; 234 235 // Reduced horizontal movement chance 236 if (random(100) < 15) { // Reduced to 15% 237 fallingParticles[i].x += random(-1, 2); 238 // Keep within boundaries 239 int currentY = fallingParticles[i].y - GLASS_Y; 240 if (currentY >= 0 && currentY < GLASS_HEIGHT) { 241 fallingParticles[i].x = constrain(fallingParticles[i].x, 242 leftBoundary[currentY], 243 rightBoundary[currentY]); 244 } 245 } 246 247 // Deactivate if reached bottom fill level 248 if (fallingParticles[i].y >= bottomLimit) { 249 fallingParticles[i].active = false; 250 } 251 } 252 } 253} 254 255// Draw the falling particles 256void drawFallingParticles() { 257 for (uint8_t i = 0; i < NUM_FALLING_PARTICLES; i++) { 258 if (fallingParticles[i].active) { 259 display.drawPixel(fallingParticles[i].x, fallingParticles[i].y); 260 } 261 } 262} 263 264// Update sand levels based on time 265void updateSandLevels() { 266 unsigned long elapsedTime = millis() - startTime; 267 float progress = (float)elapsedTime / HOURGLASS_DURATION; 268 269 // Enhanced non-linear function for more realistic hourglass behavior 270 float topProgressFactor; 271 if (progress <= 1.0) { 272 // This formula creates three distinct phases: 273 // 1. Slow initial drop (wide part) 274 // 2. Accelerating middle section (curved part) 275 // 3. Fast final drop (neck part) 276 float x = progress; 277 // Cubic function with adjustable parameters 278 topProgressFactor = 0.3 * pow(x, 3) + 0.7 * x; 279 280 // Add small random variations for more natural look 281 float randomFactor = 1.0 + (random(-10, 11) / 1000.0); // ±1% variation 282 topProgressFactor *= randomFactor; 283 } else { 284 topProgressFactor = 1.0; 285 } 286 287 // Calculate new fill percentages 288 topFillPercent = TOP_FILL_PERCENT * (1.0 - topProgressFactor); 289 290 // Bottom chamber fills proportionally to top chamber's emptying 291 bottomFillPercent = BOTTOM_FILL_PERCENT * topProgressFactor; 292 293 // Constrain values 294 topFillPercent = constrain(topFillPercent, 0, TOP_FILL_PERCENT); 295 bottomFillPercent = constrain(bottomFillPercent, 0, BOTTOM_FILL_PERCENT); 296} 297 298// Draw the sand in both chambers 299// Function to draw sand in top chamber 300void drawTopSand() { 301 int middleY = GLASS_Y + GLASS_HEIGHT / 2; 302 303 if (topFillPercent > 0) { 304 int topHeight = (GLASS_HEIGHT / 2 - TOP_THICKNESS) * topFillPercent / 100; 305 int sandTop = middleY - topHeight; 306 307 for (int y = middleY - 1; y >= sandTop; y--) { 308 if (y >= GLASS_Y + TOP_THICKNESS) { 309 int leftX = leftBoundary[y - GLASS_Y]; 310 int rightX = rightBoundary[y - GLASS_Y]; 311 312 if (y == sandTop) { 313 // Slightly uneven surface at the top 314 for (int x = leftX; x <= rightX; x++) { 315 if (random(100) < 90) { 316 display.drawPixel(x, y); 317 topPixelCount++; 318 } 319 } 320 } else { 321 // Fill complete rows 322 for (int x = leftX; x <= rightX; x++) { 323 display.drawPixel(x, y); 324 topPixelCount++; 325 } 326 } 327 } 328 } 329 } 330} 331 332// Function to draw sand in bottom chamber 333void drawBottomSand() { 334 int middleY = GLASS_Y + GLASS_HEIGHT / 2; 335 336 if (bottomFillPercent > 0) { 337 int sandBottom = GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS; 338 int maxFillHeight = (GLASS_HEIGHT / 2 - TOP_THICKNESS) * bottomFillPercent / 100; 339 int centerX = GLASS_X + GLASS_WIDTH / 2; 340 341 // Calculate current dome height based on fill percentage 342 int currentDomeHeight = std::min(maxFillHeight, (int)DOME_MAX_HEIGHT); 343 int spreadHeight = maxFillHeight - currentDomeHeight; 344 345 // Draw the main sand body (if any) 346 if (spreadHeight > 0) { 347 int flatSandTop = sandBottom - spreadHeight; 348 349 // Draw the flat accumulated sand 350 for (int y = sandBottom - 1; y >= flatSandTop; y--) { 351 if (y >= middleY) { 352 int leftX = leftBoundary[y - GLASS_Y]; 353 int rightX = rightBoundary[y - GLASS_Y]; 354 355 for (int x = leftX; x <= rightX; x++) { 356 display.drawPixel(x, y); 357 bottomPixelCount++; 358 } 359 } 360 } 361 362 // Adjust sandBottom for dome drawing 363 sandBottom = flatSandTop; 364 } 365 366 // Draw the dome shape with smoother top 367 for (int y = sandBottom; y >= sandBottom - currentDomeHeight; y--) { 368 if (y >= middleY) { 369 int leftX = leftBoundary[y - GLASS_Y]; 370 int rightX = rightBoundary[y - GLASS_Y]; 371 372 for (int x = leftX; x <= rightX; x++) { 373 int distFromCenter = abs(x - centerX); 374 int domeHeightAtDist = calculateDomeHeight(distFromCenter, currentDomeHeight); 375 376 if (sandBottom - y <= domeHeightAtDist) { 377 // Only add randomness at the very top edge of the dome 378 if (sandBottom - y == domeHeightAtDist) { 379 // Increased randomness at the dome's edge 380 if (random(100) < 70) { // 70% chance to skip pixel at the edge 381 continue; 382 } 383 } 384 display.drawPixel(x, y); 385 bottomPixelCount++; 386 } 387 } 388 } 389 } 390 } 391} 392 393 // Add some randomness to the top surface 394 /* int topSurfaceY = sandBottom - currentDomeHeight; 395 if (topSurfaceY >= middleY) { 396 int leftX = leftBoundary[topSurfaceY - GLASS_Y]; 397 int rightX = rightBoundary[topSurfaceY - GLASS_Y]; 398 399 for (int x = leftX; x <= rightX; x++) { 400 if (random(100) < 20) { 401 display.drawPixel(x, topSurfaceY - 1); 402 bottomPixelCount++; 403 } 404 } 405 } 406 } 407} 408*/ 409 410// Main draw sand function that calls both chambers 411void drawSand() { 412 topPixelCount = 0; 413 bottomPixelCount = 0; 414 415 drawTopSand(); 416 drawBottomSand(); 417} 418 419// Draw the hourglass frame - THIS WAS LIKELY MISSING OR INCOMPLETE 420void drawHourglass() { 421 int middleY = GLASS_Y + GLASS_HEIGHT / 2; 422 423 // Draw the filled walls 424 for (int y = GLASS_Y + TOP_THICKNESS; y < GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS; y++) { 425 float t; 426 float xL1, yL1, xR1, yR1; // Inner curve points 427 float xL2, yL2, xR2, yR2; // Outer curve points 428 429 if (y < middleY) { // Top half 430 t = (float)(y - (GLASS_Y + TOP_THICKNESS)) / (GLASS_HEIGHT / 2 - TOP_THICKNESS); 431 // Inner curves 432 bezierPoint(t, GLASS_X + WALL_THICKNESS - 1, GLASS_Y + TOP_THICKNESS, GLASS_X + WALL_THICKNESS - 1, GLASS_Y + GLASS_HEIGHT / 3, GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + WALL_THICKNESS - 1, middleY, xL1, yL1); 433 bezierPoint(t, GLASS_X + GLASS_WIDTH - WALL_THICKNESS + 1, GLASS_Y + TOP_THICKNESS, GLASS_X + GLASS_WIDTH - WALL_THICKNESS + 1, GLASS_Y + GLASS_HEIGHT / 3, GLASS_X + GLASS_WIDTH - (GLASS_WIDTH - NECK_TOTAL) / 2 - WALL_THICKNESS + 1, middleY, xR1, yR1); 434 // Outer curves 435 bezierPoint(t, GLASS_X - BASE_PROTRUSION + 1, GLASS_Y + TOP_THICKNESS, GLASS_X + 1, GLASS_Y + GLASS_HEIGHT / 3, GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + 1, middleY, xL2, yL2); 436 bezierPoint(t, GLASS_X + GLASS_WIDTH + BASE_PROTRUSION - 1, GLASS_Y + TOP_THICKNESS, GLASS_X + GLASS_WIDTH - 1, GLASS_Y + GLASS_HEIGHT / 3, GLASS_X + GLASS_WIDTH - (GLASS_WIDTH - NECK_TOTAL) / 2 - 1, middleY, xR2, yR2); 437 } else { // Bottom half 438 t = (float)(y - middleY) / (GLASS_HEIGHT / 2 - TOP_THICKNESS); 439 // Inner curves 440 bezierPoint(t, GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + WALL_THICKNESS - 1, middleY, GLASS_X + WALL_THICKNESS - 1, GLASS_Y + GLASS_HEIGHT - GLASS_HEIGHT / 3, GLASS_X + WALL_THICKNESS - 1, GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS, xL1, yL1); 441 bezierPoint(t, GLASS_X + GLASS_WIDTH - (GLASS_WIDTH - NECK_TOTAL) / 2 - WALL_THICKNESS + 1, middleY, GLASS_X + GLASS_WIDTH - WALL_THICKNESS + 1, GLASS_Y + GLASS_HEIGHT - GLASS_HEIGHT / 3, GLASS_X + GLASS_WIDTH - WALL_THICKNESS + 1, GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS, xR1, yR1); 442 // Outer curves 443 bezierPoint(t, GLASS_X + (GLASS_WIDTH - NECK_TOTAL) / 2 + 1, middleY, GLASS_X + 1, GLASS_Y + GLASS_HEIGHT - GLASS_HEIGHT / 3, GLASS_X - BASE_PROTRUSION + 1, GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS, xL2, yL2); 444 bezierPoint(t, GLASS_X + GLASS_WIDTH - (GLASS_WIDTH - NECK_TOTAL) / 2 - 1, middleY, GLASS_X + GLASS_WIDTH - 1, GLASS_Y + GLASS_HEIGHT - GLASS_HEIGHT / 3, GLASS_X + GLASS_WIDTH + BASE_PROTRUSION - 1, GLASS_Y + GLASS_HEIGHT - TOP_THICKNESS, xR2, yR2); 445 } 446 447 // Draw the walls 448 int xL2i = round(xL2); 449 int xR2i = round(xR2); 450 451 display.drawPixel(xL2i, y); // Left wall outer 452 display.drawPixel(xL2i + 1, y); // Left wall inner 453 display.drawPixel(xR2i, y); // Right wall outer 454 display.drawPixel(xR2i - 1, y); // Right wall inner 455 } 456 457 // Draw top and bottom bases 458 drawTopBase(true); 459 drawTopBase(false); 460} 461 462void setup() { 463 // Initialize GPIO13 as output and set it HIGH 464 pinMode(GPIO_PIN, OUTPUT); 465 digitalWrite(GPIO_PIN, HIGH); 466 467 // Initialize display with rotation based on GPIO state 468 if (digitalRead(GPIO_PIN)) { 469 display.setDisplayRotation(U8G2_R3); 470 } else { 471 display.setDisplayRotation(U8G2_R1); 472 } 473 474 // Initialize display 475 display.begin(); 476 display.setFont(u8g2_font_6x10_tf); 477 478 // Calculate boundaries for the hourglass shape 479 calculateBoundaries(); 480 481 // Initialize particles 482 initializeFallingParticles(); 483 484 // Set start time 485 startTime = millis(); 486 487 // Initialize random seed 488 randomSeed(os_random()); 489} 490 491void loop() { 492 // Check GPIO state and handle rotation if needed 493 checkGPIOAndRotation(); 494 495 // Calculate progress 496 unsigned long elapsedTime = millis() - startTime; 497 int progress = map(elapsedTime, 0, HOURGLASS_DURATION, 0, 100); 498 progress = constrain(progress, 0, 100); 499 500 // Begin drawing 501 display.clearBuffer(); 502 503 // Draw progress percentage 504 char progressStr[5]; 505 sprintf(progressStr, "%d%%", progress); 506 display.drawStr(23, 8, progressStr); 507 display.drawStr(1,127,"Sand Clock"); 508 509 // Draw all hourglass elements 510 drawHourglass(); 511 updateSandLevels(); 512 drawSand(); 513 updateFallingParticles(); 514 drawFallingParticles(); 515 516 // Send the buffer to the display 517 display.sendBuffer(); 518 519 // Check if time's up 520 if (elapsedTime >= HOURGLASS_DURATION) { 521 // Instead of showing "Time's Up", just keep showing the final state 522 startTime = millis() - HOURGLASS_DURATION; // This keeps the progress at 100% 523 } 524 525 delay(ANIMATION_DELAY); 526}
Downloadable files
Schematic
...
Schematic.jpg

Comments
Only logged in users can leave comments