An SNTP clock with multiple animations. This sketch is written for the Lilygo T-Display (240*135 px). We do not use animated GIFs. We use the graphics functions of the "TFT_eSPI.h" library to generate the animations.
The two buttons on the front of the development board allow you to rotate the screen, and switch between different animations.
The settings are stored in FLASH memory so that the selection is retained at the next startup. To avoid wearing out the FLASH memory, storage is delayed by 5 minutes, and writing is done only if there are changes.
If WiFi cannot be connected at startup, the sketch will launch a web server where you can enter the WiFi credentials, as well as the time zone. That way, the clock is always correct. Adjustments to daylight saving time or standard time are done automatically.
In most online source codes for clocks for the ESP32 or Arduino, this is not automatically adjusted, and you would need to recompile the sketch twice a year. This is not a problem for the hobbyist, but in this way, you can't, for example, gift a self-made clock to someone, let alone develop a commercial application.
Explanation of the fonts used here:
You can use any font from your own PC (or a downloaded font) on the ESP32.
Let's try the font "HP Simplified Bold" in this example.
If you don't have this on your PC you can download it from https://fonnts.com/hp-simplified/
Save this font somewhere you can find it.
Then click https://rop.nl/truetype2gfx/
Select and upload the font file.
Important: You must rename the font file before uploading it. The name is used in a few places in the library itself, so renaming afterwards will result in error messages. In this example, we rename it "HPSimplified.ttf".
Choose your desired size (e.g. 40 pt)
Then click on the button "Get GFX font file".
You can then download the font as, e.g. "HPSimplified40pt7b.h". Place it in the same folder as the sketch.
If you wish to compile the sketch below, repeat for size 20pt: "HPSimplified20pt7b.h". These names should match those in the sketch.
In the TFT_eSPI library used, a file is provided in the User_Setups folder for this board: Setup206_LilyGo_T_Display_S3.h
/*===============================================================================================================
This sketch was written for the Lilygo T-display (ESP32) with a display of 240 * 135 pixels. There are multiple
animations, selectable with the flash button (GPIO 0). The orientation of the display can be selected with the
button opposite the flash button (GPIO 35).
The preferences (animation, orientation) are saved in the flash memory after 5 minutes.
Other settings (SSID, password, time zone) are immediately stored in flash memory.
================================================================================================================*/
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h> // no need to download this library
#include <TFT_eSPI.h> // Upload font files (i.e. C:\Windows\Fonts\name.ttf) from your PC to https://rop.nl/truetype2gfx/
#include "HPSimplified40pt7b.h" // and put the result (name.h files) in the same folder as this sketch.
#include "HPSimplified20pt7b.h" // https://espgo.be/freefont.html for more details
const char* timeZone[] = {
// list of time zones: https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv
"GMT0BST,M3.5.0/1,M10.5.0", // UK, Ireland
"WET0WEST,M3.5.0/1,M10.5.0", // Portugal - Canarias - Faroe
"CET-1CEST,M3.5.0,M10.5.0/3", // Central Europe
"EET-2EEST,M3.5.0/3,M10.5.0/4", // Eastern Europa
"MSK-3", // Moskow
"GST-4", // Gulf Standard Time (VAE, Oman)
"PKT-5", // Pakistan Standard Time
"IST-5:30", // India Standard Time
"BST-6", // Bangladesh Standard Time
"ICT-7", // Indochina Time (Thailand, Vietnam, Cambodja, Laos)
"CST-8", // China Standard Time (Peking, Shanghai)
"JST-9", // Japan Standard Time
"KST-9", // Korea Standard Time
"AEST-10AEDT,M10.1.0,M4.1.0/3", // Australia (Sydney, Melbourne)
"SBT-11", // Solomon Islands Time
"NZST-12NZDT,M9.5.0,M4.1.0/3", // New-Zealand
"MIT-13", // Tonga, Samoa
"TOT-13", // Tonga Standard Time
"WST-14", // Kiritimati (Line Islands, Kiribati)
"HST10", // Hawaii-Aleutian Standard Time
"AKST9AKDT,M3.2.0,M11.1.0/2", // Alaska Time
"PST8PDT,M3.2.0,M11.1.0/2", // Pacific Time (Los Angeles, Vancouver)
"MST7MDT,M3.2.0,M11.1.0/2", // Mountain Time (Denver)
"MST7", // Phoenix / Mazatlan / Whitehorse
"CST6CDT,M3.2.0,M11.1.0/2", // Central Time (Chicago, Mexico City)
"EST5EDT,M3.2.0,M11.1.0/2", // Eastern Time (New York, Toronto)
"AST4ADT,M3.2.0,M11.1.0/2", // Atlantic Time (Halifax)
"BRT-3", // Brasília Time
"ART-3", // Argentina Standard Time
"NST-3:30NDT,M3.2.0,M11.1.0/2", // Newfoundland Time
"AZOT-1AZOST,M3.5.0/0,M10.5.0/1" // Azores
};
#define FLAKE_SIZE 1 // size of the snowflakes
#define NUM_PARTICLES 1400 // number of fire particles
#define NUM_FLAKES 768 // number of snowflakes
#define NUM_DOTS 600 // number of dots in spiral animation
#define NUM_BUBBLES 64 // number of bubbles
#define BUBBLE_SIZE 6 // bubble size
struct Particle { // struct for fire particles
float x, y; // position
float vx, vy; // speed
uint8_t r, g, b; // color
float life; // lifespan
float decay;
};
struct Snowflake {
int16_t x;
uint16_t y;
uint8_t z; // depth
int8_t w; // horizontal velocity left / right
};
struct Bubble {
int16_t x;
uint16_t y;
uint16_t w;
uint8_t z; // Depth (determines speed and size)
};
struct Gear {
int16_t x, y; // gear center
int16_t radius; // radius of the gear
uint8_t teeth; // number of teeth
float angle; // rotation angle in degrees
float speed; // rotation speed in degrees per frame
};
Particle particles[NUM_PARTICLES];
Snowflake flakes[NUM_FLAKES];
Bubble bubbles[NUM_BUBBLES];
Gear gears[] = {
{ 75, 70, 60, 36, 0, 1.0 }, // x-pos, y-pos, radius, number of gear teeth, angle, speed
{ 165, 95, 36, 24, 0, -1.5 }, // medium gear
{ 216, 80, 18, 16, 0, 2.25 } // small gear
};
Preferences flash;
WebServer server(80);
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite mySprite = TFT_eSprite(&tft);
TFT_eSprite landScape = TFT_eSprite(&tft);
TFT_eSprite hourSprite = TFT_eSprite(&tft); // sprite for hour hand
TFT_eSprite minuteSprite = TFT_eSprite(&tft); // minute hand
TFT_eSprite secondSprite = TFT_eSprite(&tft); // second hand
float dotSize = 5, rotationAngle; // dot size and angle for spiral pattern
const int clockRadius = 67, numGears = sizeof(gears) / sizeof(gears[0]);
uint16_t leftTop[70], leftBot[65]; // arrays to store the left column pixels (landscape animation)
bool showDay, showDate, wifiOk = false, settingsChanged = false, landscapeInit = false;
unsigned long lastButtonPress = 0, lastUpdate = 0, lastChangeTimestamp = 0;
const unsigned long interval = 40, saveDelay = 300000; // delay for landscape animation ; 5 minutes "save to flash" delay
const float SPEED_FACTOR = 0.05, DECAY_FACTOR = 0.005; // fire particles
struct tm tInfo; // https://cplusplus.com/reference/ctime/tm/
uint8_t view; // to save selection of animation
int tft_w, tft_h, rota; // display width, height & oriëntation
uint8_t count = 0, TIME_TOP = 0; // distance between top of display and top of font HH:MM
uint8_t r, g, b; // color fire particles
String webText, buttons, ssid, pasw; // used in html interface
char theDate[11];
const unsigned long debounceDelay = 300; // UI debouncing buttons 300 millisec
// const char* weekdays[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
const char* weekdays[] = { "zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag" };
void setup() {
pinMode(35, INPUT_PULLUP); // button opposite flash button = flip screen
pinMode(0, INPUT_PULLUP); // flash button = change the animation
readSettingsFromFlash(); // load from flash memory
displayInit();
tft.drawCentreString("Connecting to WiFi", 120, 60, 4);
connect_to_WiFi();
tft.fillScreen(TFT_BLACK);
tft.drawCentreString("Obtaining time", 120, 60, 4);
configTzTime(timeZone[count], "pool.ntp.org");
randomSeed(millis());
initBubbles();
}
void loop() {
checkButtons(); // check for button press
if (settingsChanged && millis() - lastChangeTimestamp >= saveDelay) { // if changes need to be saved: add a 5 minute delay
writeSettingsToFlash(); // save changes
settingsChanged = false; // reset flag
}
switch (view) {
case 0: // fire animation
TIME_TOP = 0;
showDay = true;
showDate = false;
updateParticles();
drawParticles();
break;
case 1: // animation of spinning gears
TIME_TOP = 0;
showDay = true;
showDate = false;
drawBackgroundGears();
updateGears();
break;
case 2: // snow animation
TIME_TOP = 20;
showDay = true;
showDate = false;
snowAnim();
break;
case 3: // animation of rotating spiral
TIME_TOP = 20;
showDay = true;
showDate = false;
updateSpiralPattern();
break;
case 4: // animation of moving landscape
TIME_TOP = 75;
showDay = false;
showDate = false;
if (!landscapeInit) initLandscape();
updateLandscape();
break;
case 5: // animation of air bubbles in water
TIME_TOP = 30;
showDay = true;
showDate = false;
bubbleAnim();
break;
case 6: // analog clock
TIME_TOP = 136; // 136 = out of range of display
drawClockHands();
showDay = false;
showDate = false;
break;
case 7: // no animation
TIME_TOP = 0;
mySprite.fillSprite(TFT_BLACK);
showDay = true;
showDate = true;
break;
default: // no animation
TIME_TOP = 0;
mySprite.fillSprite(TFT_BLACK);
showDay = true;
showDate = true;
break;
}
displayTime();
mySprite.pushSprite(0, 0); // write sprite to display
}
void displayInit() {
tft.init();
tft.setRotation(rota);
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_YELLOW);
tft_w = tft.width();
tft_h = tft.height();
mySprite.createSprite(tft_w, tft_h); // main sprite
landScape.createSprite(tft_w, 65); // trees landscape
hourSprite.createSprite(clockRadius, 4); // hour hand of analog clock
hourSprite.fillSprite(TFT_BLACK);
hourSprite.fillRect(0, 0, clockRadius / 2, 4, TFT_YELLOW);
hourSprite.drawRect(0, 0, clockRadius / 2, 4, tft.color565(176, 176, 0));
hourSprite.setPivot(0, 2);
minuteSprite.createSprite(clockRadius, 4);
minuteSprite.fillSprite(TFT_BLACK);
minuteSprite.fillRect(0, 0, clockRadius / 7 * 6, 4, TFT_YELLOW);
minuteSprite.drawRect(0, 0, clockRadius / 7 * 6, 4, tft.color565(176, 176, 0));
minuteSprite.setPivot(0, 2);
secondSprite.setColorDepth(8);
secondSprite.createSprite(clockRadius, 2);
secondSprite.fillSprite(TFT_BLACK);
secondSprite.fillRect(0, 0, clockRadius, 2, TFT_RED);
secondSprite.setPivot(0, 1);
}
void readSettingsFromFlash() { // time zone, animation, display rotation
flash.begin("my-clock", true);
String savedTimeZone = flash.getString("timezone", timeZone[0]); // default to first in list [0]
for (int i = 0; i < sizeof(timeZone) / sizeof(timeZone[0]); i++) {
if (savedTimeZone == String(timeZone[i])) {
count = i; // get time zone index
break;
}
}
view = flash.getInt("anim", 0); // retrieve the last set animation - default to 0 (fire animation)
rota = flash.getInt("rot", 3); // retrieve the last set display rotation - default to 3 (usb left side)
flash.end(); // close prefs when ready
view = view % 8; // modulo (# of elements in array) = prevent errors
}
void writeSettingsToFlash() {
int tempView, tempRota;
flash.begin("my-clock"); // flash memory (also write since 2nd param = not set)
tempView = flash.getInt("anim"); // retrieve the last set animation
tempRota = flash.getInt("rot"); // retrieve the last set display rotation
if (tempView != view || tempRota != rota) { // only write to flash memory when it was changed
flash.putInt("anim", view); // to prevent chip wear from excessive writing
flash.putInt("rot", rota);
}
flash.end();
}
void checkButtons() {
if (!digitalRead(35) && millis() - lastButtonPress > debounceDelay) { // rotate display
rota = tft.getRotation() == 3 ? 1 : 3;
tft.setRotation(rota);
markSettingsChanged();
}
if (!digitalRead(0) && millis() - lastButtonPress > debounceDelay) { // change animation
view = (view + 1) % 8;
markSettingsChanged();
}
}
void markSettingsChanged() {
lastButtonPress = millis(); // reset debounce timer
settingsChanged = true; // set flag
lastChangeTimestamp = millis(); // set timestamp
landscapeInit = false;
}
void updateParticles() { // animation fire particles
mySprite.fillSprite(TFT_BLACK); // overwrite previous frame
for (int i = 0; i < NUM_PARTICLES; i++) {
Particle& p = particles[i]; // quicker access to struct
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
if (p.life > 0.5) setColor(p, 255, random(150, 255), 0);
else if (p.life > 0.2) setColor(p, random(200, 255), random(50, 150), 0);
else setColor(p, random(100, 150), 0, 0);
if (p.life <= 0 || p.y < 0) resetParticle(p);
}
}
void setColor(Particle& p, uint8_t r, uint8_t g, uint8_t b) {
p.r = r;
p.g = g;
p.b = b;
}
void resetParticle(Particle& p) {
p.x = tft_w / 2 + random(-120, 120);
p.y = tft_h;
p.vx = random(-10, 10) * SPEED_FACTOR;
p.vy = -random(15, 30) * SPEED_FACTOR;
p.life = random(50, 100) / 100.0;
p.decay = random(3, 7) * DECAY_FACTOR;
}
void drawParticles() { // draw fire particles as colored pixels
for (int i = 0; i < NUM_PARTICLES; i++) {
mySprite.fillCircle(particles[i].x, particles[i].y, particles[i].life * 3,
mySprite.color565(particles[i].r, particles[i].g, particles[i].b));
}
mySprite.fillRect(0, 134, tft_w, 2, TFT_ORANGE);
}
void snowAnim() {
mySprite.fillSprite(TFT_BLACK);
for (int i = 0; i < NUM_FLAKES; ++i) {
if (flakes[i].z <= 1) {
flakes[i].x = random(tft_w);
flakes[i].y = random(tft_h);
flakes[i].w = (random(3) - 2) / 2.0;
flakes[i].z = random(256);
} else {
flakes[i].y += random(2);
flakes[i].x += flakes[i].w;
if (flakes[i].y > tft_h) {
flakes[i].y = 0;
flakes[i].x = random(tft_w);
flakes[i].w = (random(3) - 2) / 2.0;
}
if (flakes[i].x >= tft_w) flakes[i].x = 0;
if (flakes[i].x < 0) flakes[i].x = tft_w - 1;
if (random(5) == 0) flakes[i].w = (random(5) - 2) / 2.0;
r = g = b = 255 - flakes[i].z;
mySprite.fillCircle(flakes[i].x, flakes[i].y, FLAKE_SIZE, mySprite.color565(r, g, b));
}
}
}
void drawBackgroundGears() {
mySprite.fillRect(0, 0, tft_w, tft_h, mySprite.color565(8, 8, 8));
mySprite.drawRect(0, 0, tft_w, tft_h, mySprite.color565(32, 32, 32));
int16_t screwX[] = { 8, 8, 232, 232 }; // coordinates of the screws
int16_t screwY[] = { 8, 127, 8, 127 };
int angles[] = { 60, 90, 30, 0 }; // each screw has a different orientation
for (int i = 0; i < 4; i++) { // draw the screws
mySprite.fillCircle(screwX[i], screwY[i], 6, mySprite.color565(72, 72, 72));
float angleRad = radians(angles[i]); // calculate the line coordinates
int16_t lineX1 = screwX[i] + cos(angleRad) * 4; // starting point of the line
int16_t lineY1 = screwY[i] + sin(angleRad) * 4;
int16_t lineX2 = screwX[i] - cos(angleRad) * 4; // end point of the line
int16_t lineY2 = screwY[i] - sin(angleRad) * 4;
mySprite.drawLine(lineX1, lineY1, lineX2, lineY2, TFT_BLACK); // draw the line
}
}
void drawGear(Gear& gear) {
const float angleStep = 360.0 / gear.teeth; // angle step per tooth
const float toothWidthRatio = 0.35; // ratio for the width of the tooth
const float innerRadiusRatio = 0.9; // ratio for inner radius of tooth
for (int i = 0; i < gear.teeth; i++) {
float startAngle = radians(gear.angle + i * angleStep);
float midAngle = radians(gear.angle + (i + 0.5) * angleStep); // center of the tooth
float endAngle = radians(gear.angle + (i + 1) * angleStep);
int16_t outerX1 = gear.x + cos(startAngle) * (gear.radius * innerRadiusRatio); // calculate outer points of the tooth
int16_t outerY1 = gear.y + sin(startAngle) * (gear.radius * innerRadiusRatio);
int16_t outerX2 = gear.x + cos(endAngle) * (gear.radius * innerRadiusRatio);
int16_t outerY2 = gear.y + sin(endAngle) * (gear.radius * innerRadiusRatio);
int16_t outerTipX = gear.x + cos(midAngle) * gear.radius;
int16_t outerTipY = gear.y + sin(midAngle) * gear.radius;
mySprite.fillTriangle(outerX1, outerY1, outerX2, outerY2, outerTipX, outerTipY, mySprite.color565(96, 96, 96)); // draw tooth
}
mySprite.fillCircle(gear.x, gear.y, gear.radius * innerRadiusRatio, mySprite.color565(128, 128, 128)); // inner parts of the gear
mySprite.fillCircle(gear.x, gear.y, (gear.radius * innerRadiusRatio) - 3, mySprite.color565(64, 64, 64));
mySprite.fillCircle(gear.x, gear.y, 4, mySprite.color565(128, 128, 128));
const float holeRadius = gear.radius * innerRadiusRatio * 0.38; // draw holes in the gear: Radius of cavities
const float holeOffset = gear.radius * innerRadiusRatio * 0.55; // distance from center to hole
for (int i = 0; i < 4; i++) {
float holeAngle = radians(90 * i + gear.angle); // each 90 degrees
int16_t holeX = gear.x + cos(holeAngle) * holeOffset;
int16_t holeY = gear.y + sin(holeAngle) * holeOffset;
mySprite.fillCircle(holeX, holeY, holeRadius, TFT_BLACK);
mySprite.drawCircle(holeX, holeY, holeRadius, mySprite.color565(128, 128, 128));
mySprite.drawCircle(holeX, holeY, holeRadius - 1, mySprite.color565(128, 128, 128));
}
}
void updateGears() {
for (int i = 0; i < numGears; i++) {
gears[i].angle += gears[i].speed;
if (gears[i].angle >= 360) gears[i].angle -= 360;
if (gears[i].angle < 0) gears[i].angle += 360;
drawGear(gears[i]);
}
}
void drawSpiralPattern(int centerX, int centerY, float dotSize, float rotationAngle) {
const float goldenAngle = 137.5; // golden angle in degrees
uint16_t colors[] = { TFT_RED, TFT_ORANGE, TFT_YELLOW, TFT_GREEN, TFT_CYAN, TFT_BLUE, TFT_PURPLE };
int numColors = sizeof(colors) / sizeof(colors[0]);
for (int i = 0; i < NUM_DOTS; i++) {
float angle = i * goldenAngle * DEG_TO_RAD + rotationAngle; // add rotation angle
float radius = sqrt(i) * dotSize;
int x = centerX + cos(angle) * radius;
int y = centerY + sin(angle) * radius;
uint16_t color = colors[i % numColors];
mySprite.drawCircle(x, y, dotSize / 2, color);
}
}
void updateSpiralPattern() {
mySprite.fillSprite(TFT_BLACK);
rotationAngle -= 0.01; // slow rotation (anticlockwise)
drawSpiralPattern(120, 67, dotSize, rotationAngle);
}
void initLandscape() {
mySprite.fillRectVGradient(0, 0, tft_w, 70, TFT_NAVY, TFT_SKYBLUE); // draw the sky
int hillXPos[] = { 40, 120, -40, 200, 0, 80, 160 }; // hills: x-coordinates
for (int i = 0; i < 7; i++) { // draw hills in random green values
mySprite.fillTriangle(hillXPos[i], 70, hillXPos[i] + 80, 70, hillXPos[i] + 40, 40, tft.color565(0, random(64, 108), 0));
}
int treeXpos[] = { 15, 30, 50, 80, 95, 110, 130, 140, 155, 170, 190, 207, 231 }; // position data for trees
int numTrees = sizeof(treeXpos) / sizeof(treeXpos[0]);
for (int i = 0; i < numTrees; i++) { // draw trees, random trunk & foliage height and random green value foliage
int x = treeXpos[i];
drawTree(x, 71, tft.color565(120, 60, 0), tft.color565(0, random(64, 145), 0), random(9, 16), random(34, 52));
}
landScape.fillRectVGradient(0, 0, tft_w, 65, tft.color565(72, 72, 0), TFT_BROWN);
for (int count = 0; count < 5000; count++) { // fill foreground landscape with random short vertical lines
uint8_t x = random(tft_w);
uint8_t y = random(65);
landScape.drawLine(x, y, x, y + 3, tft.color565(0, random(90 - y, (90 - y) * 2), 0));
}
landscapeInit = true;
}
void drawTree(uint8_t left, uint8_t top, int trunkColor, int folCol, uint8_t folBot, uint8_t folTop) {
uint8_t trunkHeight = max(16, static_cast<int>(folBot));
mySprite.fillRect(left, top - trunkHeight, 5, trunkHeight, trunkColor);
uint8_t centerX = left + 2; // center of ellips X
uint8_t centerY = (((top - folBot) + (top - folTop)) / 2) - 3; // center of ellips Y
uint8_t radiusX = 6; // horizontal radius ellips
uint8_t radiusY = folTop / 2; // vertical radius ellips
mySprite.fillEllipse(centerX, centerY, radiusX, radiusY, folCol);
}
void updateLandscape() {
unsigned long currentTime = millis();
if (currentTime - lastUpdate >= interval) {
lastUpdate = currentTime;
for (int y = 0; y < 65; y++) leftBot[y] = landScape.readPixel(0, y); // read the row of pixels on the left
landScape.scroll(-1, 0); // slide the landscape to the left
for (int y = 0; y < 65; y++) landScape.drawPixel(mySprite.width() - 1, y, leftBot[y]); // write the row of pixels on the right
landScape.pushToSprite(&mySprite, 0, 70); // add the bottom sprite to the main sprite
for (int y = 0; y < 70; y++) leftTop[y] = mySprite.readPixel(0, y); // repeat the sliding for the upper part
mySprite.scroll(-1, 0);
for (int y = 0; y < 70; y++) mySprite.drawPixel(mySprite.width() - 1, y, leftTop[y]);
}
}
void initBubbles() {
for (int i = 0; i < NUM_BUBBLES; ++i) {
bubbles[i].x = random(tft_w); // random horizontal position
bubbles[i].y = random(tft_h); // random vertical position
bubbles[i].z = random(256); // random depth
}
}
void bubbleAnim() {
mySprite.fillRectVGradient(0, 0, tft_w, tft_h, TFT_BLUE, TFT_NAVY);
for (int i = 0; i < NUM_BUBBLES; ++i) {
if (bubbles[i].z <= 1) { // check if the bubbles need to be reset
bubbles[i].x = random(tft_w); // random horizontal position
bubbles[i].y = tft_h + random(BUBBLE_SIZE, 30); // start below the display
bubbles[i].w = (random(3) - 1) / 2.0; // random horizontal speed (-0.5 to 0.5)
bubbles[i].z = random(64, 256); // random depth
} else {
float depthFactor = (256.0 - bubbles[i].z) / 256.0; // depth factor
float riseSpeed = depthFactor * 2.0; // vertical speed dependent on depth
bubbles[i].y -= riseSpeed; // move the bubble up
bubbles[i].x += bubbles[i].w;
if (random(20) == 0) bubbles[i].w = (random(5) - 2) / 2.0; // randomly adjust horizontal speed
if (bubbles[i].y < 1 || bubbles[i].x < -BUBBLE_SIZE || bubbles[i].x > tft_w + BUBBLE_SIZE) { // reset bubble if out of range
bubbles[i].x = random(tft_w);
bubbles[i].y = tft_h + random(BUBBLE_SIZE, 30);
bubbles[i].w = (random(3) - 1) / 2.0;
bubbles[i].z = random(50, 256);
}
}
float positionFactor = (float)bubbles[i].y / tft_h; // relative position
int bubbleSize = BUBBLE_SIZE + (1.0 - positionFactor) * (BUBBLE_SIZE / 2);
bubbleSize = constrain(bubbleSize, 1, BUBBLE_SIZE + 4);
uint8_t brightness = 255 - bubbles[i].z; // brightness dependent on depth
brightness = constrain(brightness, 50, 255);
uint16_t bubbleColor = mySprite.color565(brightness, brightness, 255); // light blue
mySprite.drawCircle(bubbles[i].x, bubbles[i].y, bubbleSize, bubbleColor); // draw bubble
mySprite.drawCircle(bubbles[i].x, bubbles[i].y, bubbleSize - 1, bubbleColor); // draw bubble
}
}
void drawClockFace() {
float lightAngle = 270.0 * DEG_TO_RAD; // light coming from above
int outerRadiusStart = clockRadius + 5; // start radius of clock case
int outerRadiusEnd = clockRadius + 13; // end radius of clock case
for (float r = outerRadiusStart; r <= outerRadiusEnd; r += 0.5) { // small steps = fine texture
for (float angle = 0; angle < 360; angle += 0.2) {
float radian = angle * DEG_TO_RAD;
int x = tft_w / 2 + cos(radian) * r;
int y = tft_h / 2 + sin(radian) * r;
float angleDifference = cos(radian - lightAngle); // shadow intensity based on light angle
uint8_t grayValue = 160 + (angleDifference * 95); // base gray with variation (-95 to +95)
if (angleDifference > 0.9 && r > clockRadius + 10) { // stronger highlight for specific angles and radius
grayValue += 40; // brighten the area
if (grayValue > 255) grayValue = 255; // clamp value to avoid overflow
}
uint16_t color = tft.color565(grayValue, grayValue, grayValue);
mySprite.drawPixel(x, y, color);
}
}
int knobWidth = 12; // rectangular knob with ridges
int knobHeight = 30;
int knobCenterX = tft_w / 2 + clockRadius + 20; // to the right of the clock case
int knobTopY = tft_h / 2 - knobHeight / 2; // vertically centered
for (int x = knobCenterX - knobWidth / 2; x <= knobCenterX + knobWidth / 2; x++) { // drawing the knob with ribbing
for (int y = knobTopY; y <= knobTopY + knobHeight; y++) {
float normalizedX = (float)(x - (knobCenterX - knobWidth / 2)) / knobWidth; // calculate relative position
float normalizedY = (float)(y - knobTopY) / knobHeight;
float lightEffect = normalizedX * cos(lightAngle) + normalizedY * sin(lightAngle); // gradient for metal look
uint8_t grayValue = 160 + lightEffect * 80;
uint16_t color = tft.color565(grayValue, grayValue, grayValue);
if ((x - (knobCenterX - knobWidth / 2)) % 4 == 0) color = tft.color565(grayValue - 30, grayValue - 30, grayValue - 30);
mySprite.drawPixel(x, y, color);
}
}
mySprite.drawCircle(tft_w / 2, tft_h / 2, clockRadius, TFT_WHITE); // drawing the clock's edge
for (int i = 0; i < 60; i++) { // markings on the dial
float angle = i * 6.0 * DEG_TO_RAD; // 6 degrees per line
int xOuter = tft_w / 2 + cos(angle) * clockRadius;
int yOuter = tft_h / 2 + sin(angle) * clockRadius;
int markLength = (i % 5 == 0) ? 14 : 5; // long stripes for hours
int xInner = tft_w / 2 + cos(angle) * (clockRadius - markLength);
int yInner = tft_h / 2 + sin(angle) * (clockRadius - markLength);
mySprite.drawLine(xOuter, yOuter, xInner, yInner, TFT_WHITE);
}
}
void drawClockHands() {
const uint16_t colors[2][2] = {
{ tft.color565(64, 0, 0), tft.color565(128, 0, 0) },
{ tft.color565(128, 0, 0), tft.color565(64, 0, 0) }
}; // fill the screen with thin color gradients
for (int i = 0; i < 34; i++) mySprite.fillRectVGradient(0, i * 4, tft_w, 14, colors[i % 2][0], colors[i % 2][1]);
mySprite.fillCircle(tft_w / 2, tft_h / 2, clockRadius + 15, tft.color565(96, 96, 96));
mySprite.fillCircle(tft_w / 2, tft_h / 2, clockRadius + 4, TFT_BLACK);
for (int y = -clockRadius; y <= clockRadius; y++) { // fill dial with blue color gradient
for (int x = -clockRadius; x <= clockRadius; x++) {
if (x * x + y * y <= clockRadius * clockRadius) { // check if the point is inside the circle
uint8_t blueValue = (clockRadius - (y + clockRadius)) * 112 / (2 * clockRadius);
uint16_t color = tft.color565(0, 0, blueValue + 100);
mySprite.drawPixel(tft_w / 2 + x, tft_h / 2 + y, color);
}
}
}
mySprite.setTextColor(TFT_DARKGREY);
mySprite.setTextFont(1);
mySprite.drawCentreString("Lilygo", tft_w / 2, 30, 1); // "brand" of the watch
mySprite.drawCentreString("T-Display", tft_w / 2, 40, 1);
drawClockFace();
mySprite.setTextColor(TFT_YELLOW);
mySprite.fillRect((tft_w / 2) - 10, 102, 19, 13, TFT_NAVY); // box for date (day of the month)
mySprite.drawRect((tft_w / 2) - 11, 101, 21, 15, TFT_DARKGREY);
mySprite.drawCentreString(String(tInfo.tm_mday), tft_w / 2, 101, 2); // day of the month
int hour = tInfo.tm_hour;
int minute = tInfo.tm_min;
int second = tInfo.tm_sec;
float hourAngle = ((hour % 12 + minute / 60.0) * 30.0 + 270); // 30 degrees per hour
float minuteAngle = (minute + second / 60.0) * 6.0 + 270; // 6 degrees per minute
float secondAngle = second * 6.0 + 270;
hourSprite.pushRotated(&mySprite, hourAngle, TFT_BLACK); // draw the hands
minuteSprite.pushRotated(&mySprite, minuteAngle, TFT_BLACK);
secondSprite.pushRotated(&mySprite, secondAngle, TFT_BLACK);
mySprite.fillCircle(tft_w / 2, tft_h / 2, 3, TFT_RED); // central axis of the hands
mySprite.fillCircle(tft_w / 2, tft_h / 2, 1, TFT_LIGHTGREY);
}
void displayTime() { // digital time representation
getLocalTime(&tInfo); // SNTP update every 3 hours (default ESP32) since we did not set an interval
mySprite.setTextColor(TFT_CYAN, TFT_BLACK);
mySprite.setFreeFont(&HPSimplified40pt7b); // this font file can be created & downloaded at https://rop.nl/truetype2gfx/
mySprite.setCursor(2, 54 + TIME_TOP);
mySprite.printf("%02d:%02d", tInfo.tm_hour, tInfo.tm_min);
mySprite.setCursor(198, 54 + TIME_TOP);
mySprite.setFreeFont(&HPSimplified20pt7b);
mySprite.printf("%02d", tInfo.tm_sec);
mySprite.setTextColor(TFT_ORANGE);
if (showDay) mySprite.drawCentreString(weekdays[tInfo.tm_wday], 120, 64 + TIME_TOP, 1); // sunray animation
if (showDate) {
strftime(theDate, sizeof(theDate), "%d-%m-%G", &tInfo); // https://cplusplus.com/reference/ctime/strftime
mySprite.setTextColor(TFT_GREEN);
mySprite.drawCentreString(theDate, 120, 104, 1);
}
}
void showMessageNoConnection() { // message on display when there is no WiFi connection
const char* noConnec[] = { "WiFi: no connection.", "Connect to hotspot", "'Lily', open browser",
"address 192.168.4.1", "for login + password." };
tft.fillScreen(TFT_NAVY);
tft.setTextColor(TFT_YELLOW);
for (uint8_t i = 0; i < 5; i++) tft.drawCentreString(noConnec[i], 120, i * 27, 4);
}
void connect_to_WiFi() {
WiFi.mode(WIFI_MODE_STA);
flash.begin("login_data", true);
ssid = flash.getString("ssid", "");
pasw = flash.getString("pasw", "");
flash.end();
WiFi.begin(ssid.c_str(), pasw.c_str());
for (uint8_t i = 0; i < 50; ++i) {
if (WiFi.isConnected()) {
WiFi.setAutoReconnect(true);
WiFi.persistent(true);
return;
}
delay(160);
}
showMessageNoConnection();
WiFi.disconnect(true);
delay(100);
WiFi.scanDelete();
delay(100);
int n = WiFi.scanNetworks();
buttons = ""; // empty old data
if (n == 0) buttons = "<p>No networks found</p>";
else {
for (int i = 0; i < n; ++i) { // html to display found networks on buttons
buttons += "\n<button onclick='scrollNaar(this.id)' id='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) + "</button><br>";
}
}
WiFi.mode(WIFI_MODE_AP);
WiFi.softAP("Lily", "", 1);
server.on("/", server_Root);
server.on("/setting", server_Setting);
server.begin();
for (;;) server.handleClient();
}
void server_Root() {
const char* timezones[] = {
"London (GMT/BST)", "Portugal / Canarias (WET/WEST)", "Brussels / Berlin(CET/CEST)", "Athens (EET/EEST)", "Moscow (MSK)",
"Dubai (GST)", "Karachi (PKT)", "Delhi (IST)", "Dhaka (BST)", "Bangkok (ICT)", "Shanghai (CST)",
"Tokyo (JST)", "Seoul (KST)", "Sydney (AEST/AEDT)", "Honiara (SBT)", "Auckland (NZST/NZDT)",
"Apia (MIT)", "Nuku'alofa (TOT)", "Kiritimati (WST)", "Honolulu (HST)", "Anchorage (AKST/AKDT)",
"Los Angeles (PST/PDT)", "Denver (MST/MDT)", "Phoenix (MST)", "Mexico City (CST/CDT)", "New York (EST/EDT)",
"Halifax (AST/ADT)", "São Paulo (BRT)", "Buenos Aires (ART)", "St. John's (NST/NDT)", "Ponta Delgada (AZOT/AZOST)"
};
webText = "<!DOCTYPE HTML>\n<html lang='en'>\n<head><title>Setup</title>\n<meta name='viewport' ";
webText += "content='width=device-width, initial-scale=1.0'>\n<style>\np {\n font-family: Arial, Helvetica, sans-serif;\n";
webText += " font-size: 18px;\n margin: 0;\n text-align: center;\n}\n\n";
webText += "button, input[type=submit] {\n width: 250px;\n border-radius: 5px;\n color: White;\n padding: 4px 4px;\n";
webText += " margin-top: 16px;\n margin: 0 auto;\n display:block;\n font-size: 18px;\n font-weight: 600;\n ";
webText += "background: DodgerBlue;\n}\n\n";
webText += "input, select {\n width: 250px;\n font-size: 18px;\n font-weight: 600;\n}\n</style>\n</head>\n<body>";
webText += "<p style='font-family:arial; font-size:240%;'>WiFi setup\n</p>";
webText += "<p style='font-family:arial; font-size:160%;'><br>Click on item to select or<br>Enter your network data<br>";
webText += "and time zone<br>in the boxes below. <br>Networks found: </p><br>";
webText += buttons;
webText += "\n<form method='get' action='setting'>\n<p><b>\nSSID: <br>\n<input id='ssid' name='ssid'>";
webText += "<br>PASW: </b><br>\n<input type='password' name='pass'><br><br>";
webText += "Time Zone: <br>\n<select name='timezone'>";
for (int i = 0; i < sizeof(timeZone) / sizeof(timeZone[0]); i++) {
webText += "\n<option value='" + String(i) + "'";
if (i == count) webText += " selected";
webText += ">" + String(timezones[i]) + "</option>";
}
webText += "\n</select><br><br>";
webText += "<input type='submit' value='Save'>";
webText += "\n</p>\n</form>\n<script>\nfunction scrollNaar(tekst) {\n document.getElementById('ssid').value = tekst;\n";
webText += " window.scrollTo(0, document.body.scrollHeight);\n}\n</script>\n</body>\n</html>";
server.send(200, "text/html", webText);
}
void server_Setting() {
String myssid = server.arg("ssid");
String mypasw = server.arg("pass");
String mytimezone = server.arg("timezone");
if (myssid.length() > 0) {
flash.begin("login_data", false);
flash.putString("ssid", myssid);
flash.putString("pasw", mypasw);
flash.end();
}
if (mytimezone.length() > 0) {
flash.begin("my-clock", false);
flash.putString("timezone", timeZone[mytimezone.toInt()]); // Sla de tijdzone-string op
flash.end();
}
webText = "<!DOCTYPE HTML>\n<html lang='en'>\n<head><title>Setup</title>\n<meta name='viewport' ";
webText += "content='width=device-width, initial-scale=1.0'>\n<style>\n* {\n font-family: Arial, Helvetica";
webText += ", sans-serif;\n font-size: 45px;\n font-weight: 600;\n margin: 0;\n text-align: center;\n}";
webText += "\n\n@keyframes op_en_neer {\n 0% {height: 0px;}\n 50% {height: 40px;}\n 100% {height: 0px;}\n}";
webText += "\n\n.opneer {\n margin: auto;\n text-align: center;\n animation: op_en_neer 2s infinite;\n}";
webText += "\n</style>\n</head>\n<body>\n<div class=\"opneer\"></div>\nESP will reboot<br>Close this window";
webText += "\n</body>\n</html>";
server.send(200, "\ntext/html", webText);
delay(500);
ESP.restart();
}