Files
ESPTimeCast/ESPTimeCast.ino
M-Factory c6e8e1c76b Added "Dramatic Countdown"
Changes to API call handling, added countdown
2025-07-28 14:14:42 +09:00

1908 lines
68 KiB
C++

#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <sntp.h>
#include <time.h>
#include "mfactoryfont.h" // Custom font
#include "tz_lookup.h" // Timezone lookup, do not duplicate mapping here!
#include "days_lookup.h" // Languages for the Days of the Week
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 4
#define CLK_PIN 12
#define DATA_PIN 15
#define CS_PIN 13
MD_Parola P = MD_Parola(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES);
AsyncWebServer server(80);
// --- Global Scroll Speed Settings ---
const int GENERAL_SCROLL_SPEED = 85; // Default: Adjust this for Weather Description and Countdown Label (e.g., 50 for faster, 200 for slower)
const int IP_SCROLL_SPEED = 115; // Default: Adjust this for the IP Address display (slower for readability)
// WiFi and configuration globals
char ssid[32] = "";
char password[32] = "";
char openWeatherApiKey[64] = "";
char openWeatherCity[64] = "";
char openWeatherCountry[64] = "";
char weatherUnits[12] = "metric";
char timeZone[64] = "";
char language[8] = "en";
String mainDesc = "";
String detailedDesc = "";
// Timing and display settings
unsigned long clockDuration = 10000;
unsigned long weatherDuration = 5000;
int brightness = 7;
bool flipDisplay = false;
bool twelveHourToggle = false;
bool showDayOfWeek = true;
bool showHumidity = false;
char ntpServer1[64] = "pool.ntp.org";
char ntpServer2[64] = "time.nist.gov";
// Dimming
bool dimmingEnabled = false;
int dimStartHour = 18; // 6pm default
int dimStartMinute = 0;
int dimEndHour = 8; // 8am default
int dimEndMinute = 0;
int dimBrightness = 2; // Dimming level (0-15)
//Countdown Globals - NEW
bool countdownEnabled = false;
time_t countdownTargetTimestamp = 0; // Unix timestamp
char countdownLabel[64] = ""; // Label for the countdown
// State management
bool weatherCycleStarted = false;
WiFiClient client;
const byte DNS_PORT = 53;
DNSServer dnsServer;
String currentTemp = "";
String weatherDescription = "";
bool showWeatherDescription = false;
bool weatherAvailable = false;
bool weatherFetched = false;
bool weatherFetchInitiated = false;
bool isAPMode = false;
char tempSymbol = '[';
bool shouldFetchWeatherNow = false;
unsigned long lastSwitch = 0;
unsigned long lastColonBlink = 0;
int displayMode = 0; // 0: Clock, 1: Weather, 2: Weather Description, 3: Countdown
int currentHumidity = -1;
bool ntpSyncSuccessful = false;
// NTP Synchronization State Machine
enum NtpState {
NTP_IDLE,
NTP_SYNCING,
NTP_SUCCESS,
NTP_FAILED
};
NtpState ntpState = NTP_IDLE;
unsigned long ntpStartTime = 0;
const int ntpTimeout = 30000; // 30 seconds
const int maxNtpRetries = 30;
int ntpRetryCount = 0;
unsigned long lastNtpStatusPrintTime = 0;
const unsigned long ntpStatusPrintInterval = 1000; // Print status every 1 seconds (adjust as needed)
// Non-blocking IP display globals
bool showingIp = false;
int ipDisplayCount = 0;
const int ipDisplayMax = 2; // As per working copy for how long IP shows
String pendingIpToShow = "";
// Countdown display state - NEW
bool countdownScrolling = false;
unsigned long countdownScrollEndTime = 0;
unsigned long countdownStaticStartTime = 0; // For last-day static display
// --- NEW GLOBAL VARIABLES FOR IMMEDIATE COUNTDOWN FINISH ---
bool countdownFinished = false; // Tracks if the countdown has permanently finished
bool countdownShowFinishedMessage = false; // Flag to indicate "TIMES UP" message is active
unsigned long countdownFinishedMessageStartTime = 0; // Timer for the 10-second message duration
unsigned long lastFlashToggleTime = 0; // For controlling the flashing speed
bool currentInvertState = false; // Current state of display inversion for flashing
static bool hourglassPlayed = false;
// Weather Description Mode handling
unsigned long descStartTime = 0; // For static description
bool descScrolling = false;
const unsigned long descriptionDuration = 3000; // 3s for short text
static unsigned long descScrollEndTime = 0; // for post-scroll delay (re-used for scroll timing)
const unsigned long descriptionScrollPause = 300; // 300ms pause after scroll
// Scroll flipped
textEffect_t getEffectiveScrollDirection(textEffect_t desiredDirection, bool isFlipped) {
if (isFlipped) {
// If the display is horizontally flipped, reverse the horizontal scroll direction
if (desiredDirection == PA_SCROLL_LEFT) {
return PA_SCROLL_RIGHT;
} else if (desiredDirection == PA_SCROLL_RIGHT) {
return PA_SCROLL_LEFT;
}
}
return desiredDirection;
}
// -----------------------------------------------------------------------------
// Configuration Load & Save
// -----------------------------------------------------------------------------
void loadConfig() {
Serial.println(F("[CONFIG] Loading configuration..."));
// Check if config.json exists, if not, create default
if (!LittleFS.exists("/config.json")) {
Serial.println(F("[CONFIG] config.json not found, creating with defaults..."));
DynamicJsonDocument doc(1024);
doc[F("ssid")] = "";
doc[F("password")] = "";
doc[F("openWeatherApiKey")] = "";
doc[F("openWeatherCity")] = "";
doc[F("openWeatherCountry")] = "";
doc[F("weatherUnits")] = "metric";
doc[F("clockDuration")] = 10000;
doc[F("weatherDuration")] = 5000;
doc[F("timeZone")] = "";
doc[F("language")] = "en";
doc[F("brightness")] = brightness;
doc[F("flipDisplay")] = flipDisplay;
doc[F("twelveHourToggle")] = twelveHourToggle;
doc[F("showDayOfWeek")] = showDayOfWeek;
doc[F("showHumidity")] = showHumidity;
doc[F("ntpServer1")] = ntpServer1;
doc[F("ntpServer2")] = ntpServer2;
doc[F("dimmingEnabled")] = dimmingEnabled;
doc[F("dimStartHour")] = dimStartHour;
doc[F("dimStartMinute")] = dimStartMinute;
doc[F("dimEndHour")] = dimEndHour;
doc[F("dimEndMinute")] = dimEndMinute;
doc[F("dimBrightness")] = dimBrightness;
doc[F("showWeatherDescription")] = showWeatherDescription;
// Add countdown defaults when creating a new config.json
JsonObject countdownObj = doc.createNestedObject("countdown");
countdownObj["enabled"] = false;
countdownObj["targetTimestamp"] = 0;
countdownObj["label"] = "";
File f = LittleFS.open("/config.json", "w");
if (f) {
serializeJsonPretty(doc, f);
f.close();
Serial.println(F("[CONFIG] Default config.json created."));
} else {
Serial.println(F("[ERROR] Failed to create default config.json"));
}
}
Serial.println(F("[CONFIG] Attempting to open config.json for reading."));
File configFile = LittleFS.open("/config.json", "r");
if (!configFile) {
Serial.println(F("[ERROR] Failed to open config.json for reading. Cannot load config."));
return;
}
DynamicJsonDocument doc(1024); // Size based on ArduinoJson Assistant + buffer
DeserializationError error = deserializeJson(doc, configFile);
configFile.close();
if (error) {
Serial.print(F("[ERROR] JSON parse failed during load: "));
Serial.println(error.f_str());
return;
}
strlcpy(ssid, doc["ssid"] | "", sizeof(ssid));
strlcpy(password, doc["password"] | "", sizeof(password));
strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey)); // Corrected typo here
strlcpy(openWeatherCity, doc["openWeatherCity"] | "", sizeof(openWeatherCity));
strlcpy(openWeatherCountry, doc["openWeatherCountry"] | "", sizeof(openWeatherCountry));
strlcpy(weatherUnits, doc["weatherUnits"] | "metric", sizeof(weatherUnits));
clockDuration = doc["clockDuration"] | 10000;
weatherDuration = doc["weatherDuration"] | 5000;
strlcpy(timeZone, doc["timeZone"] | "Etc/UTC", sizeof(timeZone));
if (doc.containsKey("language")) {
strlcpy(language, doc["language"], sizeof(language));
} else {
strlcpy(language, "en", sizeof(language));
Serial.println(F("[CONFIG] 'language' key not found in config.json, defaulting to 'en'."));
}
brightness = doc["brightness"] | 7;
flipDisplay = doc["flipDisplay"] | false;
twelveHourToggle = doc["twelveHourToggle"] | false;
showDayOfWeek = doc["showDayOfWeek"] | true;
showHumidity = doc["showHumidity"] | false;
String de = doc["dimmingEnabled"].as<String>();
dimmingEnabled = (de == "true" || de == "on" || de == "1");
dimStartHour = doc["dimStartHour"] | 18;
dimStartMinute = doc["dimStartMinute"] | 0;
dimEndHour = doc["dimEndHour"] | 8;
dimEndMinute = doc["dimEndMinute"] | 0;
dimBrightness = doc["dimBrightness"] | 0;
strlcpy(ntpServer1, doc["ntpServer1"] | "pool.ntp.org", sizeof(ntpServer1));
strlcpy(ntpServer2, doc["ntpServer2"] | "time.nist.gov", sizeof(ntpServer2));
if (strcmp(weatherUnits, "imperial") == 0)
tempSymbol = ']';
else
tempSymbol = '[';
if (doc.containsKey("showWeatherDescription"))
showWeatherDescription = doc["showWeatherDescription"];
else
showWeatherDescription = false;
// --- COUNTDOWN CONFIG LOADING ---
if (doc.containsKey("countdown")) {
JsonObject countdownObj = doc["countdown"];
countdownEnabled = countdownObj["enabled"] | false;
countdownTargetTimestamp = countdownObj["targetTimestamp"] | 0;
JsonVariant labelVariant = countdownObj["label"];
if (labelVariant.isNull() || !labelVariant.is<const char *>()) {
strcpy(countdownLabel, "");
} else {
const char *labelTemp = labelVariant.as<const char *>();
size_t labelLen = strlen(labelTemp);
if (labelLen >= sizeof(countdownLabel)) {
Serial.println(F("[CONFIG] label from JSON too long, truncating."));
}
strlcpy(countdownLabel, labelTemp, sizeof(countdownLabel));
}
countdownFinished = false;
} else {
countdownEnabled = false;
countdownTargetTimestamp = 0;
strcpy(countdownLabel, "");
Serial.println(F("[CONFIG] Countdown object not found, defaulting to disabled."));
countdownFinished = false;
}
Serial.println(F("[CONFIG] Configuration loaded."));
}
// -----------------------------------------------------------------------------
// WiFi Setup
// -----------------------------------------------------------------------------
const char *DEFAULT_AP_PASSWORD = "12345678";
const char *AP_SSID = "ESPTimeCast";
void connectWiFi() {
Serial.println(F("[WIFI] Connecting to WiFi..."));
bool credentialsExist = (strlen(ssid) > 0);
if (!credentialsExist) {
Serial.println(F("[WIFI] No saved credentials. Starting AP mode directly."));
WiFi.mode(WIFI_AP);
WiFi.disconnect(true);
delay(100);
if (strlen(DEFAULT_AP_PASSWORD) < 8) {
WiFi.softAP(AP_SSID);
Serial.println(F("[WIFI] AP Mode started (no password, too short)."));
} else {
WiFi.softAP(AP_SSID, DEFAULT_AP_PASSWORD);
Serial.println(F("[WIFI] AP Mode started."));
}
IPAddress apIP(192, 168, 4, 1);
WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
Serial.print(F("[WIFI] AP IP address: "));
Serial.println(WiFi.softAPIP());
isAPMode = true;
WiFiMode_t mode = WiFi.getMode();
Serial.printf("[WIFI] WiFi mode after setting AP: %s\n",
mode == WIFI_OFF ? "OFF" : mode == WIFI_STA ? "STA ONLY"
: mode == WIFI_AP ? "AP ONLY"
: mode == WIFI_AP_STA ? "AP + STA (Error!)"
: "UNKNOWN");
Serial.println(F("[WIFI] AP Mode Started"));
return;
}
// If credentials exist, attempt STA connection
WiFi.mode(WIFI_STA);
WiFi.disconnect(true);
delay(100);
WiFi.begin(ssid, password);
unsigned long startAttemptTime = millis();
const unsigned long timeout = 30000;
unsigned long animTimer = 0;
int animFrame = 0;
bool animating = true;
while (animating) {
unsigned long now = millis();
if (WiFi.status() == WL_CONNECTED) {
Serial.println(F("[WIFI] Connected: ") + WiFi.localIP().toString());
isAPMode = false;
WiFiMode_t mode = WiFi.getMode();
Serial.printf("[WIFI] WiFi mode after STA connection: %s\n",
mode == WIFI_OFF ? "OFF" : mode == WIFI_STA ? "STA ONLY"
: mode == WIFI_AP ? "AP ONLY"
: mode == WIFI_AP_STA ? "AP + STA (Error!)"
: "UNKNOWN");
// --- IP Display initiation ---
pendingIpToShow = WiFi.localIP().toString();
showingIp = true;
ipDisplayCount = 0; // Reset count for IP display
P.displayClear();
P.setCharSpacing(1); // Set spacing for IP scroll
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayScroll(pendingIpToShow.c_str(), PA_CENTER, actualScrollDirection, IP_SCROLL_SPEED);
// --- END IP Display initiation ---
animating = false; // Exit the connection loop
break;
} else if (now - startAttemptTime >= timeout) {
Serial.println(F("[WiFi] Failed. Starting AP mode..."));
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, DEFAULT_AP_PASSWORD);
Serial.print(F("[WiFi] AP IP address: "));
Serial.println(WiFi.softAPIP());
dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
isAPMode = true;
WiFiMode_t mode = WiFi.getMode();
Serial.printf("[WIFI] WiFi mode after STA failure and setting AP: %s\n",
mode == WIFI_OFF ? "OFF" : mode == WIFI_STA ? "STA ONLY"
: mode == WIFI_AP ? "AP ONLY"
: mode == WIFI_AP_STA ? "AP + STA (Error!)"
: "UNKNOWN");
animating = false;
Serial.println(F("[WIFI] AP Mode Started"));
break;
}
if (now - animTimer > 750) {
animTimer = now;
P.setTextAlignment(PA_CENTER);
switch (animFrame % 3) {
case 0: P.print(F("# ©")); break;
case 1: P.print(F("# ª")); break;
case 2: P.print(F("# «")); break;
}
animFrame++;
}
yield();
}
}
// -----------------------------------------------------------------------------
// Time / NTP Functions
// -----------------------------------------------------------------------------
void setupTime() {
sntp_stop();
if (!isAPMode) {
Serial.println(F("[TIME] Starting NTP sync"));
}
configTime(0, 0, ntpServer1, ntpServer2);
setenv("TZ", ianaToPosix(timeZone), 1);
tzset();
ntpState = NTP_SYNCING;
ntpStartTime = millis();
ntpRetryCount = 0;
ntpSyncSuccessful = false;
}
// -----------------------------------------------------------------------------
// Utility
// -----------------------------------------------------------------------------
void printConfigToSerial() {
Serial.println(F("========= Loaded Configuration ========="));
Serial.print(F("WiFi SSID: "));
Serial.println(ssid);
Serial.print(F("WiFi Password: "));
Serial.println(password);
Serial.print(F("OpenWeather City: "));
Serial.println(openWeatherCity);
Serial.print(F("OpenWeather Country: "));
Serial.println(openWeatherCountry);
Serial.print(F("OpenWeather API Key: "));
Serial.println(openWeatherApiKey);
Serial.print(F("Temperature Unit: "));
Serial.println(weatherUnits);
Serial.print(F("Clock duration: "));
Serial.println(clockDuration);
Serial.print(F("Weather duration: "));
Serial.println(weatherDuration);
Serial.print(F("TimeZone (IANA): "));
Serial.println(timeZone);
Serial.print(F("Days of the Week/Weather description language: "));
Serial.println(language);
Serial.print(F("Brightness: "));
Serial.println(brightness);
Serial.print(F("Flip Display: "));
Serial.println(flipDisplay ? "Yes" : "No");
Serial.print(F("Show 12h Clock: "));
Serial.println(twelveHourToggle ? "Yes" : "No");
Serial.print(F("Show Day of the Week: "));
Serial.println(showDayOfWeek ? "Yes" : "No");
Serial.print(F("Show Weather Description: "));
Serial.println(showWeatherDescription ? "Yes" : "No");
Serial.print(F("Show Humidity "));
Serial.println(showHumidity ? "Yes" : "No");
Serial.print(F("NTP Server 1: "));
Serial.println(ntpServer1);
Serial.print(F("NTP Server 2: "));
Serial.println(ntpServer2);
Serial.print(F("Dimming Enabled: "));
Serial.println(dimmingEnabled);
Serial.print(F("Dimming Start Hour: "));
Serial.println(dimStartHour);
Serial.print(F("Dimming Start Minute: "));
Serial.println(dimStartMinute);
Serial.print(F("Dimming End Hour: "));
Serial.println(dimEndHour);
Serial.print(F("Dimming End Minute: "));
Serial.println(dimEndMinute);
Serial.print(F("Dimming Brightness: "));
Serial.println(dimBrightness);
Serial.print(F("Countdown Enabled: "));
Serial.println(countdownEnabled ? "Yes" : "No");
Serial.print(F("Countdown Target Timestamp: "));
Serial.println(countdownTargetTimestamp);
Serial.print(F("Countdown Label: "));
Serial.println(countdownLabel);
Serial.println(F("========================================"));
Serial.println();
}
// -----------------------------------------------------------------------------
// Web Server and Captive Portal
// -----------------------------------------------------------------------------
void handleCaptivePortal(AsyncWebServerRequest *request);
void setupWebServer() {
Serial.println(F("[WEBSERVER] Setting up web server..."));
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
Serial.println(F("[WEBSERVER] Request: /"));
request->send(LittleFS, "/index.html", "text/html");
});
server.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *request) {
Serial.println(F("[WEBSERVER] Request: /config.json"));
File f = LittleFS.open("/config.json", "r");
if (!f) {
Serial.println(F("[WEBSERVER] Error opening /config.json"));
request->send(500, "application/json", "{\"error\":\"Failed to open config.json\"}");
return;
}
DynamicJsonDocument doc(2048);
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.print(F("[WEBSERVER] Error parsing /config.json: "));
Serial.println(err.f_str());
request->send(500, "application/json", "{\"error\":\"Failed to parse config.json\"}");
return;
}
doc[F("mode")] = isAPMode ? "ap" : "sta";
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request) {
Serial.println(F("[WEBSERVER] Request: /save"));
DynamicJsonDocument doc(2048);
File configFile = LittleFS.open("/config.json", "r");
if (configFile) {
Serial.println(F("[WEBSERVER] Existing config.json found, loading for update..."));
DeserializationError err = deserializeJson(doc, configFile);
configFile.close();
if (err) {
Serial.print(F("[WEBSERVER] Error parsing existing config.json: "));
Serial.println(err.f_str());
}
} else {
Serial.println(F("[WEBSERVER] config.json not found, starting with empty doc for save."));
}
for (int i = 0; i < request->params(); i++) {
const AsyncWebParameter *p = request->getParam(i);
String n = p->name();
String v = p->value();
if (n == "brightness") doc[n] = v.toInt();
else if (n == "clockDuration") doc[n] = v.toInt();
else if (n == "weatherDuration") doc[n] = v.toInt();
else if (n == "flipDisplay") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "twelveHourToggle") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "showDayOfWeek") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "showHumidity") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "dimStartHour") doc[n] = v.toInt();
else if (n == "dimStartMinute") doc[n] = v.toInt();
else if (n == "dimEndHour") doc[n] = v.toInt();
else if (n == "dimEndMinute") doc[n] = v.toInt();
else if (n == "dimBrightness") doc[n] = v.toInt();
else if (n == "showWeatherDescription") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "dimmingEnabled") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "weatherUnits") doc[n] = v;
else {
doc[n] = v;
}
}
bool newCountdownEnabled = (request->hasParam("countdownEnabled", true) && (request->getParam("countdownEnabled", true)->value() == "true" || request->getParam("countdownEnabled", true)->value() == "on" || request->getParam("countdownEnabled", true)->value() == "1"));
String countdownDateStr = request->hasParam("countdownDate", true) ? request->getParam("countdownDate", true)->value() : "";
String countdownTimeStr = request->hasParam("countdownTime", true) ? request->getParam("countdownTime", true)->value() : "";
String countdownLabelStr = request->hasParam("countdownLabel", true) ? request->getParam("countdownLabel", true)->value() : "";
time_t newTargetTimestamp = 0;
if (newCountdownEnabled && countdownDateStr.length() > 0 && countdownTimeStr.length() > 0) {
int year = countdownDateStr.substring(0, 4).toInt();
int month = countdownDateStr.substring(5, 7).toInt();
int day = countdownDateStr.substring(8, 10).toInt();
int hour = countdownTimeStr.substring(0, 2).toInt();
int minute = countdownTimeStr.substring(3, 5).toInt();
struct tm tm;
tm.tm_year = year - 1900;
tm.tm_mon = month - 1;
tm.tm_mday = day;
tm.tm_hour = hour;
tm.tm_min = minute;
tm.tm_sec = 0;
tm.tm_isdst = -1;
newTargetTimestamp = mktime(&tm);
if (newTargetTimestamp == (time_t)-1) {
Serial.println("[SAVE] Error converting countdown date/time to timestamp.");
newTargetTimestamp = 0;
} else {
Serial.printf("[SAVE] Converted countdown target: %s -> %lu\n", countdownDateStr.c_str(), newTargetTimestamp);
}
}
JsonObject countdownObj = doc.createNestedObject("countdown");
countdownObj["enabled"] = newCountdownEnabled;
countdownObj["targetTimestamp"] = newTargetTimestamp;
countdownObj["label"] = countdownLabelStr;
FSInfo fs_info;
LittleFS.info(fs_info);
Serial.printf("[SAVE] LittleFS total bytes: %u, used bytes: %u\n", fs_info.totalBytes, fs_info.usedBytes);
if (LittleFS.exists("/config.json")) {
Serial.println(F("[SAVE] Renaming /config.json to /config.bak"));
LittleFS.rename("/config.json", "/config.bak");
}
File f = LittleFS.open("/config.json", "w");
if (!f) {
Serial.println(F("[SAVE] ERROR: Failed to open /config.json for writing!"));
DynamicJsonDocument errorDoc(256);
errorDoc[F("error")] = "Failed to write config file.";
String response;
serializeJson(errorDoc, response);
request->send(500, "application/json", response);
return;
}
size_t bytesWritten = serializeJson(doc, f);
Serial.printf("[SAVE] Bytes written to /config.json: %u\n", bytesWritten);
f.close();
Serial.println(F("[SAVE] /config.json file closed."));
File verify = LittleFS.open("/config.json", "r");
if (!verify) {
Serial.println(F("[SAVE] ERROR: Failed to open /config.json for reading during verification!"));
DynamicJsonDocument errorDoc(256);
errorDoc[F("error")] = "Verification failed: Could not re-open config file.";
String response;
serializeJson(errorDoc, response);
request->send(500, "application/json", response);
return;
}
while (verify.available()) {
verify.read();
}
verify.seek(0);
DynamicJsonDocument test(2048);
DeserializationError err = deserializeJson(test, verify);
verify.close();
if (err) {
Serial.print(F("[SAVE] Config corrupted after save: "));
Serial.println(err.f_str());
DynamicJsonDocument errorDoc(256);
errorDoc[F("error")] = String("Config corrupted. Reboot cancelled. Error: ") + err.f_str();
String response;
serializeJson(errorDoc, response);
request->send(500, "application/json", response);
return;
}
Serial.println(F("[SAVE] Config verification successful."));
DynamicJsonDocument okDoc(128);
okDoc[F("message")] = "Saved successfully. Rebooting...";
String response;
serializeJson(okDoc, response);
request->send(200, "application/json", response);
Serial.println(F("[WEBSERVER] Sending success response and scheduling reboot..."));
request->onDisconnect([]() {
Serial.println(F("[WEBSERVER] Client disconnected, rebooting ESP..."));
ESP.restart();
});
});
server.on("/restore", HTTP_POST, [](AsyncWebServerRequest *request) {
Serial.println(F("[WEBSERVER] Request: /restore"));
if (LittleFS.exists("/config.bak")) {
File src = LittleFS.open("/config.bak", "r");
if (!src) {
Serial.println(F("[WEBSERVER] Failed to open /config.bak"));
DynamicJsonDocument errorDoc(128);
errorDoc[F("error")] = "Failed to open backup file.";
String response;
serializeJson(errorDoc, response);
request->send(500, "application/json", response);
return;
}
File dst = LittleFS.open("/config.json", "w");
if (!dst) {
src.close();
Serial.println(F("[WEBSERVER] Failed to open /config.json for writing"));
DynamicJsonDocument errorDoc(128);
errorDoc[F("error")] = "Failed to open config for writing.";
String response;
serializeJson(errorDoc, response);
request->send(500, "application/json", response);
return;
}
while (src.available()) {
dst.write(src.read());
}
src.close();
dst.close();
DynamicJsonDocument okDoc(128);
okDoc[F("message")] = "✅ Backup restored! Device will now reboot.";
String response;
serializeJson(okDoc, response);
request->send(200, "application/json", response);
request->onDisconnect([]() {
Serial.println(F("[WEBSERVER] Rebooting after restore..."));
ESP.restart();
});
} else {
Serial.println(F("[WEBSERVER] No backup found"));
DynamicJsonDocument errorDoc(128);
errorDoc[F("error")] = "No backup found.";
String response;
serializeJson(errorDoc, response);
request->send(404, "application/json", response);
}
});
server.on("/ap_status", HTTP_GET, [](AsyncWebServerRequest *request) {
Serial.print(F("[WEBSERVER] Request: /ap_status. isAPMode = "));
Serial.println(isAPMode);
String json = "{\"isAP\": ";
json += (isAPMode) ? "true" : "false";
json += "}";
request->send(200, "application/json", json);
});
server.on("/set_brightness", HTTP_POST, [](AsyncWebServerRequest *request) {
if (!request->hasParam("value", true)) {
request->send(400, "application/json", "{\"error\":\"Missing value\"}");
return;
}
int newBrightness = request->getParam("value", true)->value().toInt();
if (newBrightness < 0) newBrightness = 0;
if (newBrightness > 15) newBrightness = 15;
brightness = newBrightness;
P.setIntensity(brightness);
Serial.printf("[WEBSERVER] Set brightness to %d\n", brightness);
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_flip", HTTP_POST, [](AsyncWebServerRequest *request) {
bool flip = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
flip = (v == "1" || v == "true" || v == "on");
}
flipDisplay = flip;
P.setZoneEffect(0, flipDisplay, PA_FLIP_UD);
P.setZoneEffect(0, flipDisplay, PA_FLIP_LR);
Serial.printf("[WEBSERVER] Set flipDisplay to %d\n", flipDisplay);
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_twelvehour", HTTP_POST, [](AsyncWebServerRequest *request) {
bool twelveHour = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
twelveHour = (v == "1" || v == "true" || v == "on");
}
twelveHourToggle = twelveHour;
Serial.printf("[WEBSERVER] Set twelveHourToggle to %d\n", twelveHourToggle);
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_dayofweek", HTTP_POST, [](AsyncWebServerRequest *request) {
bool showDay = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
showDay = (v == "1" || v == "true" || v == "on");
}
showDayOfWeek = showDay;
Serial.printf("[WEBSERVER] Set showDayOfWeek to %d\n", showDayOfWeek);
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_humidity", HTTP_POST, [](AsyncWebServerRequest *request) {
bool showHumidityNow = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
showHumidityNow = (v == "1" || v == "true" || v == "on");
}
showHumidity = showHumidityNow;
Serial.printf("[WEBSERVER] Set showHumidity to %d\n", showHumidity);
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_language", HTTP_POST, [](AsyncWebServerRequest *request) {
if (!request->hasParam("value", true)) {
request->send(400, "application/json", "{\"error\":\"Missing value\"}");
return;
}
String lang = request->getParam("value", true)->value();
strlcpy(language, lang.c_str(), sizeof(language));
Serial.printf("[WEBSERVER] Set language to %s\n", language);
shouldFetchWeatherNow = true;
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_weatherdesc", HTTP_POST, [](AsyncWebServerRequest *request) {
bool showDesc = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
showDesc = (v == "1" || v == "true" || v == "on");
}
if (showWeatherDescription == true && showDesc == false) {
Serial.println(F("[WEBSERVER] showWeatherDescription toggled OFF. Checking display mode..."));
if (displayMode == 2) {
Serial.println(F("[WEBSERVER] Currently in Weather Description mode. Forcing mode advance/cleanup."));
advanceDisplayMode();
}
}
showWeatherDescription = showDesc;
Serial.printf("[WEBSERVER] Set Show Weather Description to %d\n", showWeatherDescription);
request->send(200, "application/json", "{\"ok\":true}");
});
server.on("/set_units", HTTP_POST, [](AsyncWebServerRequest *request) {
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
if (v == "1" || v == "true" || v == "on") {
strcpy(weatherUnits, "imperial");
tempSymbol = ']';
} else {
strcpy(weatherUnits, "metric");
tempSymbol = '[';
}
Serial.printf("[WEBSERVER] Set weatherUnits to %s\n", weatherUnits);
shouldFetchWeatherNow = true;
request->send(200, "application/json", "{\"ok\":true}");
} else {
request->send(400, "application/json", "{\"error\":\"Missing value parameter\"}");
}
});
server.on("/set_countdown_enabled", HTTP_POST, [](AsyncWebServerRequest *request) {
bool enableCountdownNow = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
enableCountdownNow = (v == "1" || v == "true" || v == "on");
}
if (countdownEnabled == enableCountdownNow) {
Serial.println(F("[WEBSERVER] Countdown enable state unchanged, ignoring."));
request->send(200, "application/json", "{\"ok\":true}");
return;
}
if (countdownEnabled == true && enableCountdownNow == false) {
Serial.println(F("[WEBSERVER] Countdown toggled OFF. Checking display mode..."));
if (displayMode == 3) {
Serial.println(F("[WEBSERVER] Currently in Countdown mode. Forcing mode advance/cleanup."));
advanceDisplayMode();
}
}
countdownEnabled = enableCountdownNow;
Serial.printf("[WEBSERVER] Set Countdown Enabled to %d\n", countdownEnabled);
request->send(200, "application/json", "{\"ok\":true}");
});
server.begin();
Serial.println(F("[WEBSERVER] Web server started"));
}
void handleCaptivePortal(AsyncWebServerRequest *request) {
Serial.print(F("[WEBSERVER] Captive Portal Redirecting: "));
Serial.println(request->url());
request->redirect(String("http://") + WiFi.softAPIP().toString() + "/");
}
String normalizeWeatherDescription(String str) {
str.replace("å", "a");
str.replace("ä", "a");
str.replace("à", "a");
str.replace("á", "a");
str.replace("â", "a");
str.replace("ã", "a");
str.replace("ā", "a");
str.replace("ă", "a");
str.replace("ą", "a");
str.replace("æ", "ae");
str.replace("ç", "c");
str.replace("č", "c");
str.replace("ć", "c");
str.replace("ď", "d");
str.replace("é", "e");
str.replace("è", "e");
str.replace("ê", "e");
str.replace("ë", "e");
str.replace("ē", "e");
str.replace("ė", "e");
str.replace("ę", "e");
str.replace("ğ", "g");
str.replace("ģ", "g");
str.replace("ĥ", "h");
str.replace("í", "i");
str.replace("ì", "i");
str.replace("î", "i");
str.replace("ï", "i");
str.replace("ī", "i");
str.replace("į", "i");
str.replace("ĵ", "j");
str.replace("ķ", "k");
str.replace("ľ", "l");
str.replace("ł", "l");
str.replace("ñ", "n");
str.replace("ń", "n");
str.replace("ņ", "n");
str.replace("ó", "o");
str.replace("ò", "o");
str.replace("ô", "o");
str.replace("ö", "o");
str.replace("õ", "o");
str.replace("ø", "o");
str.replace("ō", "o");
str.replace("ő", "o");
str.replace("œ", "oe");
str.replace("ŕ", "r");
str.replace("ś", "s");
str.replace("š", "s");
str.replace("ș", "s");
str.replace("ŝ", "s");
str.replace("ß", "ss");
str.replace("ť", "t");
str.replace("ț", "t");
str.replace("ú", "u");
str.replace("ù", "u");
str.replace("û", "u");
str.replace("ü", "u");
str.replace("ū", "u");
str.replace("ů", "u");
str.replace("ű", "u");
str.replace("ŵ", "w");
str.replace("ý", "y");
str.replace("ÿ", "y");
str.replace("ŷ", "y");
str.replace("ž", "z");
str.replace("ź", "z");
str.replace("ż", "z");
str.toLowerCase();
String result = "";
for (unsigned int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if ((c >= 'a' && c <= 'z') || c == ' ') {
result += c;
}
}
return result;
}
bool isNumber(const char *str) {
for (int i = 0; str[i]; i++) {
if (!isdigit(str[i]) && str[i] != '.' && str[i] != '-') return false;
}
return true;
}
bool isFiveDigitZip(const char *str) {
if (strlen(str) != 5) return false;
for (int i = 0; i < 5; i++) {
if (!isdigit(str[i])) return false;
}
return true;
}
// -----------------------------------------------------------------------------
// Weather Fetching and API settings
// -----------------------------------------------------------------------------
String buildWeatherURL() {
String base = "http://api.openweathermap.org/data/2.5/weather?";
float lat = atof(openWeatherCity);
float lon = atof(openWeatherCountry);
bool latValid = isNumber(openWeatherCity) && isNumber(openWeatherCountry) && lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0;
if (latValid) {
base += "lat=" + String(lat, 8) + "&lon=" + String(lon, 8);
} else if (isFiveDigitZip(openWeatherCity) && String(openWeatherCountry).equalsIgnoreCase("US")) {
base += "zip=" + String(openWeatherCity) + "," + String(openWeatherCountry);
} else {
base += "q=" + String(openWeatherCity) + "," + String(openWeatherCountry);
}
base += "&appid=" + String(openWeatherApiKey);
base += "&units=" + String(weatherUnits);
String langForAPI = String(language);
if (langForAPI == "eo" || langForAPI == "sw" || langForAPI == "ja") {
langForAPI = "en";
}
base += "&lang=" + langForAPI;
return base;
}
void fetchWeather() {
Serial.println(F("[WEATHER] Fetching weather data..."));
if (WiFi.status() != WL_CONNECTED) {
Serial.println(F("[WEATHER] Skipped: WiFi not connected"));
weatherAvailable = false;
weatherFetched = false;
return;
}
if (!openWeatherApiKey || strlen(openWeatherApiKey) != 32) {
Serial.println(F("[WEATHER] Skipped: Invalid API key (must be exactly 32 characters)"));
weatherAvailable = false;
weatherFetched = false;
return;
}
if (!(strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0)) {
Serial.println(F("[WEATHER] Skipped: City or Country is empty."));
weatherAvailable = false;
return;
}
Serial.println(F("[WEATHER] Connecting to OpenWeatherMap..."));
String url = buildWeatherURL();
Serial.println(F("[WEATHER] URL: ") + url);
HTTPClient http; // Create an HTTPClient object
WiFiClient client; // Create a WiFiClient object
http.begin(client, url); // Pass the WiFiClient object and the URL
http.setTimeout(10000); // Sets both connection and stream timeout to 10 seconds
Serial.println(F("[WEATHER] Sending GET request..."));
int httpCode = http.GET(); // Send the GET request
if (httpCode == HTTP_CODE_OK) { // Check if HTTP response code is 200 (OK)
Serial.println(F("[WEATHER] HTTP 200 OK. Reading payload..."));
String payload = http.getString();
Serial.println(F("[WEATHER] Response received."));
Serial.println(F("[WEATHER] Payload: ") + payload);
DynamicJsonDocument doc(1536); // Adjust size as needed, use ArduinoJson Assistant
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print(F("[WEATHER] JSON parse error: "));
Serial.println(error.f_str());
weatherAvailable = false;
return;
}
if (doc.containsKey(F("main")) && doc[F("main")].containsKey(F("temp"))) {
float temp = doc[F("main")][F("temp")];
currentTemp = String((int)round(temp)) + "º";
Serial.printf("[WEATHER] Temp: %s\n", currentTemp.c_str());
weatherAvailable = true;
} else {
Serial.println(F("[WEATHER] Temperature not found in JSON payload"));
weatherAvailable = false;
return;
}
if (doc.containsKey(F("main")) && doc[F("main")].containsKey(F("humidity"))) {
currentHumidity = doc[F("main")][F("humidity")];
Serial.printf("[WEATHER] Humidity: %d%%\n", currentHumidity);
} else {
currentHumidity = -1;
}
if (doc.containsKey(F("weather")) && doc[F("weather")].is<JsonArray>()) {
JsonObject weatherObj = doc[F("weather")][0];
if (weatherObj.containsKey(F("main"))) {
mainDesc = weatherObj[F("main")].as<String>();
}
if (weatherObj.containsKey(F("description"))) {
detailedDesc = weatherObj[F("description")].as<String>();
}
} else {
Serial.println(F("[WEATHER] Weather description not found in JSON payload"));
}
weatherDescription = normalizeWeatherDescription(detailedDesc);
Serial.printf("[WEATHER] Description used: %s\n", weatherDescription.c_str());
weatherFetched = true;
} else {
Serial.printf("[WEATHER] HTTP GET failed, error code: %d, reason: %s\n", httpCode, http.errorToString(httpCode).c_str());
weatherAvailable = false;
weatherFetched = false;
}
http.end();
}
// -----------------------------------------------------------------------------
// Main setup() and loop()
// -----------------------------------------------------------------------------
/*
DisplayMode key:
0: Clock
1: Weather
2: Weather Description
3: Countdown (NEW)
*/
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println();
Serial.println(F("[SETUP] Starting setup..."));
if (!LittleFS.begin()) {
Serial.println(F("[ERROR] LittleFS mount failed in setup! Halting."));
while (true) {
delay(1000);
yield();
}
}
Serial.println(F("[SETUP] LittleFS file system mounted successfully."));
P.begin(); // Initialize Parola library
P.setCharSpacing(0);
P.setFont(mFactory);
loadConfig(); // This function now has internal yields and prints
P.setIntensity(brightness);
P.setZoneEffect(0, flipDisplay, PA_FLIP_UD);
P.setZoneEffect(0, flipDisplay, PA_FLIP_LR);
Serial.println(F("[SETUP] Parola (LED Matrix) initialized"));
connectWiFi();
if (isAPMode) {
Serial.println(F("[SETUP] WiFi connection failed. Device is in AP Mode."));
} else if (WiFi.status() == WL_CONNECTED) {
Serial.println(F("[SETUP] WiFi connected successfully to local network."));
} else {
Serial.println(F("[SETUP] WiFi state is uncertain after connection attempt."));
}
setupWebServer();
Serial.println(F("[SETUP] Webserver setup complete"));
Serial.println(F("[SETUP] Setup complete"));
Serial.println();
printConfigToSerial();
setupTime();
displayMode = 0;
lastSwitch = millis();
lastColonBlink = millis();
}
void advanceDisplayMode() {
int oldMode = displayMode; // Store the old mode
// Determine the next display mode based on the current mode and conditions
if (displayMode == 0) { // Current mode is Clock
if (weatherAvailable && (strlen(openWeatherApiKey) == 32) && (strlen(openWeatherCity) > 0) && (strlen(openWeatherCountry) > 0)) {
displayMode = 1; // Clock -> Weather (if weather is available and configured)
Serial.println(F("[DISPLAY] Switching to display mode: WEATHER (from Clock)"));
} else if (countdownEnabled && !countdownFinished && ntpSyncSuccessful) {
displayMode = 3; // Clock -> Countdown (if weather is NOT available/configured, but countdown is)
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Clock, weather skipped)"));
} else {
displayMode = 0; // Clock -> Clock (if neither weather nor countdown is available)
Serial.println(F("[DISPLAY] Staying in CLOCK (from Clock, no weather/countdown available)"));
}
} else if (displayMode == 1) { // Current mode is Weather
if (showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) {
displayMode = 2; // Weather -> Description (if description is enabled and available)
Serial.println(F("[DISPLAY] Switching to display mode: DESCRIPTION (from Weather)"));
} else if (countdownEnabled && !countdownFinished && ntpSyncSuccessful) {
displayMode = 3; // Weather -> Countdown (if description is NOT enabled/available, but countdown is)
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Weather)"));
} else {
displayMode = 0; // Weather -> Clock (if neither description nor countdown is available)
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Weather)"));
}
} else if (displayMode == 2) { // Current mode is Weather Description
if (countdownEnabled && !countdownFinished && ntpSyncSuccessful) {
displayMode = 3; // Description -> Countdown (if countdown is valid)
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Description)"));
} else {
displayMode = 0; // Description -> Clock (if countdown is NOT valid)
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Description)"));
}
} else if (displayMode == 3) { // Current mode is Countdown
displayMode = 0; // Countdown -> Clock (always return to clock after countdown)
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Countdown)"));
}
// --- Common cleanup/reset after mode switch ---
lastSwitch = millis(); // Reset the timer for the new mode's duration
// Reset variables specifically for the mode being ENTERED
if (displayMode == 3) { // Entering Countdown mode
countdownScrolling = false; // Ensure scrolling starts from beginning
countdownStaticStartTime = 0; // Reset static display timer
}
// Clear display and reset flags when EXITING specific modes
if (oldMode == 2 && displayMode != 2) { // Exiting Description Mode
P.displayClear();
descScrolling = false;
descStartTime = 0;
descScrollEndTime = 0;
Serial.println(F("[DISPLAY] Cleared display after exiting Description Mode."));
}
if (oldMode == 3 && displayMode != 3) { // Exiting Countdown Mode
P.displayClear();
countdownScrolling = false;
countdownStaticStartTime = 0;
countdownScrollEndTime = 0;
Serial.println(F("[DISPLAY] Cleared display after exiting Countdown Mode."));
}
}
//config save after countdown finishes
bool saveCountdownConfig(bool enabled, time_t targetTimestamp, const String &label) {
DynamicJsonDocument doc(2048);
File configFile = LittleFS.open("/config.json", "r");
if (configFile) {
DeserializationError err = deserializeJson(doc, configFile);
configFile.close();
if (err) {
Serial.print(F("[saveCountdownConfig] Error parsing config.json: "));
Serial.println(err.f_str());
return false;
}
}
JsonObject countdownObj = doc["countdown"].is<JsonObject>() ? doc["countdown"].as<JsonObject>() : doc.createNestedObject("countdown");
countdownObj["enabled"] = enabled;
countdownObj["targetTimestamp"] = targetTimestamp;
countdownObj["label"] = label;
doc.remove("countdownEnabled");
doc.remove("countdownDate");
doc.remove("countdownTime");
doc.remove("countdownLabel");
if (LittleFS.exists("/config.json")) {
LittleFS.rename("/config.json", "/config.bak");
}
File f = LittleFS.open("/config.json", "w");
if (!f) {
Serial.println(F("[saveCountdownConfig] ERROR: Cannot write to /config.json"));
return false;
}
size_t bytesWritten = serializeJson(doc, f);
f.close();
Serial.printf("[saveCountdownConfig] Config updated. %u bytes written.\n", bytesWritten);
return true;
}
void loop() {
if (isAPMode) {
dnsServer.processNextRequest();
}
static bool colonVisible = true;
const unsigned long colonBlinkInterval = 800;
if (millis() - lastColonBlink > colonBlinkInterval) {
colonVisible = !colonVisible;
lastColonBlink = millis();
}
static unsigned long ntpAnimTimer = 0;
static int ntpAnimFrame = 0;
static bool tzSetAfterSync = false;
static unsigned long lastFetch = 0;
const unsigned long fetchInterval = 300000; // 5 minutes
// AP Mode animation
static unsigned long apAnimTimer = 0;
static int apAnimFrame = 0;
if (isAPMode) {
unsigned long now = millis();
if (now - apAnimTimer > 750) {
apAnimTimer = now;
apAnimFrame++;
}
P.setTextAlignment(PA_CENTER);
switch (apAnimFrame % 3) {
case 0: P.print(F("= ©")); break;
case 1: P.print(F("= ª")); break;
case 2: P.print(F("= «")); break;
}
yield();
return;
}
// Dimming
time_t now_time = time(nullptr);
struct tm timeinfo;
localtime_r(&now_time, &timeinfo);
int curHour = timeinfo.tm_hour;
int curMinute = timeinfo.tm_min;
int curTotal = curHour * 60 + curMinute;
int startTotal = dimStartHour * 60 + dimStartMinute;
int endTotal = dimEndHour * 60 + dimEndMinute;
bool isDimmingActive = false;
if (dimmingEnabled) {
if (startTotal < endTotal) {
isDimmingActive = (curTotal >= startTotal && curTotal < endTotal);
} else { // Overnight dimming
isDimmingActive = (curTotal >= startTotal || curTotal < endTotal);
}
if (isDimmingActive) {
P.setIntensity(dimBrightness);
} else {
P.setIntensity(brightness);
}
} else {
P.setIntensity(brightness);
}
// --- IMMEDIATE COUNTDOWN FINISH TRIGGER ---
if (countdownEnabled && !countdownFinished && ntpSyncSuccessful && countdownTargetTimestamp > 0 && now_time >= countdownTargetTimestamp) {
countdownFinished = true;
displayMode = 3; // Let main loop handle animation + TIMES UP
countdownShowFinishedMessage = true;
hourglassPlayed = false;
countdownFinishedMessageStartTime = millis();
Serial.println("[SYSTEM] Countdown target reached! Switching to Mode 3 to display finish sequence.");
yield();
}
// --- IP Display ---
if (showingIp) {
if (P.displayAnimate()) {
ipDisplayCount++;
if (ipDisplayCount < ipDisplayMax) {
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayScroll(pendingIpToShow.c_str(), PA_CENTER, actualScrollDirection, 120);
} else {
showingIp = false;
P.displayClear();
delay(500); // Blocking delay as in working copy
displayMode = 0;
lastSwitch = millis();
}
}
yield();
return; // Exit loop early if showing IP
}
// --- NTP State Machine ---
switch (ntpState) {
case NTP_IDLE: break;
case NTP_SYNCING:
{
time_t now = time(nullptr);
if (now > 1000) { // NTP sync successful
Serial.println(F("[TIME] NTP sync successful."));
ntpSyncSuccessful = true;
ntpState = NTP_SUCCESS;
} else if (millis() - ntpStartTime > ntpTimeout || ntpRetryCount >= maxNtpRetries) {
Serial.println(F("[TIME] NTP sync failed."));
ntpSyncSuccessful = false;
ntpState = NTP_FAILED;
} else {
// Periodically print a more descriptive status message
if (millis() - lastNtpStatusPrintTime >= ntpStatusPrintInterval) {
Serial.printf("[TIME] NTP sync in progress (attempt %d of %d)...\n", ntpRetryCount + 1, maxNtpRetries);
lastNtpStatusPrintTime = millis();
}
// Still increment ntpRetryCount based on your original timing for the timeout logic
// (even if you don't print a dot for every increment)
if (millis() - ntpStartTime > ((unsigned long)(ntpRetryCount + 1) * 1000UL)) {
ntpRetryCount++;
}
}
break;
}
case NTP_SUCCESS:
if (!tzSetAfterSync) {
const char *posixTz = ianaToPosix(timeZone);
setenv("TZ", posixTz, 1);
tzset();
tzSetAfterSync = true;
}
ntpAnimTimer = 0;
ntpAnimFrame = 0;
break;
case NTP_FAILED:
ntpAnimTimer = 0;
ntpAnimFrame = 0;
break;
}
// Only advance mode by timer for clock/weather, not description!
unsigned long displayDuration = (displayMode == 0) ? clockDuration : weatherDuration;
if ((displayMode == 0 || displayMode == 1) && millis() - lastSwitch > displayDuration) {
advanceDisplayMode();
}
// --- MODIFIED WEATHER FETCHING LOGIC ---
if (WiFi.status() == WL_CONNECTED) {
if (!weatherFetchInitiated || shouldFetchWeatherNow || (millis() - lastFetch > fetchInterval)) {
if (shouldFetchWeatherNow) {
Serial.println(F("[LOOP] Immediate weather fetch requested by web server."));
shouldFetchWeatherNow = false;
} else if (!weatherFetchInitiated) {
Serial.println(F("[LOOP] Initial weather fetch."));
} else {
Serial.println(F("[LOOP] Regular interval weather fetch."));
}
weatherFetchInitiated = true;
weatherFetched = false;
fetchWeather();
lastFetch = millis();
}
} else {
weatherFetchInitiated = false;
shouldFetchWeatherNow = false;
}
const char *const *daysOfTheWeek = getDaysOfWeek(language);
const char *daySymbol = daysOfTheWeek[timeinfo.tm_wday];
char timeStr[9];
if (twelveHourToggle) {
int hour12 = timeinfo.tm_hour % 12;
if (hour12 == 0) hour12 = 12;
sprintf(timeStr, " %d:%02d", hour12, timeinfo.tm_min);
} else {
sprintf(timeStr, " %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
}
char timeSpacedStr[20];
int j = 0;
for (int i = 0; timeStr[i] != '\0'; i++) {
timeSpacedStr[j++] = timeStr[i];
if (timeStr[i + 1] != '\0') {
timeSpacedStr[j++] = ' ';
}
}
timeSpacedStr[j] = '\0';
String formattedTime;
if (showDayOfWeek) {
formattedTime = String(daySymbol) + " " + String(timeSpacedStr);
} else {
formattedTime = String(timeSpacedStr);
}
unsigned long currentDisplayDuration = 0;
if (displayMode == 0) {
currentDisplayDuration = clockDuration;
} else if (displayMode == 1) { // Weather
currentDisplayDuration = weatherDuration;
}
// Only advance mode by timer for clock/weather static (Mode 0 & 1).
// Other modes (2, 3) have their own internal timers/conditions for advancement.
if ((displayMode == 0 || displayMode == 1) && (millis() - lastSwitch > currentDisplayDuration)) {
advanceDisplayMode();
}
// --- CLOCK Display Mode ---
if (displayMode == 0) {
P.setCharSpacing(0);
if (ntpState == NTP_SYNCING) {
if (ntpSyncSuccessful || ntpRetryCount >= maxNtpRetries || millis() - ntpStartTime > ntpTimeout) {
// Avoid being stuck here if something went wrong in state management
ntpState = NTP_FAILED;
} else {
if (millis() - ntpAnimTimer > 750) {
ntpAnimTimer = millis();
switch (ntpAnimFrame % 3) {
case 0: P.print(F("S Y N C ®")); break;
case 1: P.print(F("S Y N C ¯")); break;
case 2: P.print(F("S Y N C °")); break;
}
ntpAnimFrame++;
}
}
} else if (!ntpSyncSuccessful) {
P.setTextAlignment(PA_CENTER);
static unsigned long errorAltTimer = 0;
static bool showNtpError = true;
// Toggle every 2 seconds if both are unavailable
if (!ntpSyncSuccessful && !weatherAvailable) {
if (millis() - errorAltTimer > 2000) {
errorAltTimer = millis();
showNtpError = !showNtpError;
}
if (showNtpError) {
P.print(F("?/")); // NTP error glyph
} else {
P.print(F("?*")); // Weather error glyph
}
} else if (!ntpSyncSuccessful) {
P.print(F("?/")); // NTP only
} else if (!weatherAvailable) {
P.print(F("?*")); // Weather only
}
} else {
// NTP and weather are OK — show time
String timeString = formattedTime;
if (!colonVisible) timeString.replace(":", " ");
P.print(timeString);
}
yield();
return;
}
// --- WEATHER Display Mode ---
static bool weatherWasAvailable = false;
if (displayMode == 1) {
P.setCharSpacing(1);
if (weatherAvailable) {
String weatherDisplay;
if (showHumidity && currentHumidity != -1) {
int cappedHumidity = (currentHumidity > 99) ? 99 : currentHumidity;
weatherDisplay = currentTemp + " " + String(cappedHumidity) + "%";
} else {
weatherDisplay = currentTemp + tempSymbol;
}
P.print(weatherDisplay.c_str());
weatherWasAvailable = true;
} else {
if (weatherWasAvailable) {
Serial.println(F("[DISPLAY] Weather not available, showing clock..."));
weatherWasAvailable = false;
}
if (ntpSyncSuccessful) {
String timeString = formattedTime;
if (!colonVisible) timeString.replace(":", " ");
P.setCharSpacing(0);
P.print(timeString);
} else {
P.setCharSpacing(0);
P.setTextAlignment(PA_CENTER);
P.print(F("?*"));
}
}
yield();
return;
}
// --- WEATHER DESCRIPTION Display Mode ---
if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) {
String desc = weatherDescription;
desc.toUpperCase();
if (desc.length() > 8) {
if (!descScrolling) {
P.displayClear();
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayScroll(desc.c_str(), PA_CENTER, actualScrollDirection, GENERAL_SCROLL_SPEED);
descScrolling = true;
descScrollEndTime = 0; // reset end time at start
}
if (P.displayAnimate()) {
if (descScrollEndTime == 0) {
descScrollEndTime = millis(); // mark the time when scroll finishes
}
// wait small pause after scroll stops
if (millis() - descScrollEndTime > descriptionScrollPause) {
descScrolling = false;
descScrollEndTime = 0;
advanceDisplayMode();
}
} else {
descScrollEndTime = 0; // reset if not finished
}
yield();
return;
} else {
if (descStartTime == 0) {
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
P.print(desc.c_str());
descStartTime = millis();
}
if (millis() - descStartTime > descriptionDuration) {
descStartTime = 0;
advanceDisplayMode();
}
yield();
return;
}
}
// --- Countdown Display Mode ---
if (displayMode == 3 && countdownEnabled && ntpSyncSuccessful) {
static int countdownSegment = 0;
static unsigned long segmentStartTime = 0;
const unsigned long SEGMENT_DISPLAY_DURATION = 1500; // 1.5 seconds for each static segment
long timeRemaining = countdownTargetTimestamp - now_time;
// --- Countdown Finished Logic ---
// This 'if' block now handles the entire "finished" sequence (hourglass + flashing).
if (timeRemaining <= 0 || countdownShowFinishedMessage) {
// NEW: Only show "TIMES UP" if countdown target timestamp is valid and expired
time_t now = time(nullptr);
if (countdownTargetTimestamp == 0 || countdownTargetTimestamp > now) {
// Target invalid or in the future, don't show "TIMES UP" yet, advance display instead
countdownShowFinishedMessage = false;
countdownFinished = false;
countdownFinishedMessageStartTime = 0;
hourglassPlayed = false; // Reset if we decide not to show it
Serial.println("[COUNTDOWN-FINISH] Countdown target invalid or not reached yet, skipping 'TIMES UP'. Advancing display.");
advanceDisplayMode();
yield();
return;
}
// Define these static variables here if they are not global (or already defined in your loop())
static const char *flashFrames[] = { "{|", "}~" };
static unsigned long lastFlashingSwitch = 0;
static int flashingMessageFrame = 0;
// --- Initial Combined Sequence: Play Hourglass THEN start Flashing ---
// This 'if' runs ONLY ONCE when the "finished" sequence begins.
if (!hourglassPlayed) { // <-- This is the single entry point for the combined sequence
countdownFinished = true; // Mark as finished overall
countdownShowFinishedMessage = true; // Confirm we are in the finished sequence
countdownFinishedMessageStartTime = millis(); // Start the 15-second timer for the flashing duration
// 1. Play Hourglass Animation (Blocking)
const char *hourglassFrames[] = { "¡", "¢", "£", "¤" };
for (int repeat = 0; repeat < 3; repeat++) {
for (int i = 0; i < 4; i++) {
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(0);
P.print(hourglassFrames[i]);
delay(350); // This is blocking! (Total ~4.2 seconds for hourglass)
}
}
Serial.println("[COUNTDOWN-FINISH] Played hourglass animation.");
P.displayClear(); // Clear display after hourglass animation
// 2. Initialize Flashing "TIMES UP" for its very first frame
flashingMessageFrame = 0;
lastFlashingSwitch = millis(); // Set initial time for first flash frame
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(0);
P.print(flashFrames[flashingMessageFrame]); // Display the first frame immediately
flashingMessageFrame = (flashingMessageFrame + 1) % 2; // Prepare for the next frame
hourglassPlayed = true; // <-- Mark that this initial combined sequence has completed!
countdownSegment = 0; // Reset segment counter after finished sequence initiation
segmentStartTime = 0; // Reset segment timer after finished sequence initiation
}
// --- Continue Flashing "TIMES UP" for its duration (after initial combined sequence) ---
// This part runs in subsequent loop iterations after the hourglass has played.
if (millis() - countdownFinishedMessageStartTime < 15000) { // Flashing duration
if (millis() - lastFlashingSwitch >= 500) { // Check for flashing interval
lastFlashingSwitch = millis();
P.displayClear();
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(0);
P.print(flashFrames[flashingMessageFrame]);
flashingMessageFrame = (flashingMessageFrame + 1) % 2;
}
P.displayAnimate(); // Ensure display updates
yield();
return; // Stay in this mode until the 15 seconds are over
} else {
// 15 seconds are over, clean up and advance
Serial.println("[COUNTDOWN-FINISH] Flashing duration over. Advancing to Clock.");
countdownShowFinishedMessage = false;
countdownFinishedMessageStartTime = 0;
hourglassPlayed = false; // <-- RESET this flag for the next countdown cycle!
// Final cleanup (persisted)
countdownEnabled = false;
countdownTargetTimestamp = 0;
countdownLabel[0] = '\0';
saveCountdownConfig(false, 0, "");
P.setInvert(false);
advanceDisplayMode();
yield();
return; // Exit loop after processing
}
} // END of 'if (timeRemaining <= 0 || countdownShowFinishedMessage)'
// --- Normal Countdown Segments (Only if not in finished state) ---
// This 'else' block will only run if `timeRemaining > 0` and `!countdownShowFinishedMessage`
else {
long days = timeRemaining / (24 * 3600);
long hours = (timeRemaining % (24 * 3600)) / 3600;
long minutes = (timeRemaining % 3600) / 60;
long seconds = timeRemaining % 60;
String currentSegmentText = "";
if (segmentStartTime == 0 || (millis() - segmentStartTime > SEGMENT_DISPLAY_DURATION)) {
segmentStartTime = millis();
P.displayClear();
switch (countdownSegment) {
case 0: // Days
if (days > 0) {
currentSegmentText = String(days) + " " + (days == 1 ? "DAY" : "DAYS");
Serial.printf("[COUNTDOWN-STATIC] Displaying segment %d: %s\n", countdownSegment, currentSegmentText.c_str());
countdownSegment++;
} else {
// Skip days if zero
countdownSegment++;
segmentStartTime = 0;
}
break;
case 1:
{ // Hours
char buf[10];
sprintf(buf, "%02ld HRS", hours); // pad hours with 0
currentSegmentText = String(buf);
Serial.printf("[COUNTDOWN-STATIC] Displaying segment %d: %s\n", countdownSegment, currentSegmentText.c_str());
countdownSegment++;
break;
}
case 2:
{ // Minutes
char buf[10];
sprintf(buf, "%02ld MINS", minutes); // pad minutes with 0
currentSegmentText = String(buf);
Serial.printf("[COUNTDOWN-STATIC] Displaying segment %d: %s\n", countdownSegment, currentSegmentText.c_str());
countdownSegment++;
break;
}
case 3:
{
// --- Otherwise, run countdown segments like before ---
time_t segmentStartTime = time(nullptr); // Get fixed start time
unsigned long segmentStartMillis = millis(); // Capture start millis for delta
long nowRemaining = countdownTargetTimestamp - segmentStartTime;
long currentSecond = nowRemaining % 60;
char secondsBuf[10];
sprintf(secondsBuf, "%02ld %s", currentSecond, currentSecond == 1 ? "SEC" : "SECS");
String secondsText = String(secondsBuf);
Serial.printf("[COUNTDOWN-STATIC] Displaying segment 3: %s\n", secondsText.c_str());
P.displayClear();
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
P.print(secondsText.c_str());
delay(SEGMENT_DISPLAY_DURATION - 400); // Show the first seconds value slightly shorter
unsigned long elapsed = millis() - segmentStartMillis;
long adjustedSecond = (countdownTargetTimestamp - segmentStartTime - (elapsed / 1000)) % 60;
sprintf(secondsBuf, "%02ld %s", adjustedSecond, adjustedSecond == 1 ? "SEC" : "SECS");
secondsText = String(secondsBuf);
P.displayClear();
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
P.print(secondsText.c_str());
delay(400); // Short burst to show the updated second clearly
String label;
if (strlen(countdownLabel) > 0) {
label = String(countdownLabel);
label.trim();
if (!label.startsWith("TO:") && !label.startsWith("to:")) {
label = "TO: " + label;
}
label.replace('.', ',');
} else {
static const char *fallbackLabels[] = {
"TO: PARTY TIME!",
"TO: SHOWTIME!",
"TO: CLOCKOUT!",
"TO: BLASTOFF!",
"TO: GO TIME!",
"TO: LIFTOFF!",
"TO: THE BIG REVEAL!",
"TO: ZERO HOUR!",
"TO: THE FINAL COUNT!",
"TO: MISSION COMPLETE"
};
int randomIndex = random(0, 10);
label = fallbackLabels[randomIndex];
}
P.setTextAlignment(PA_LEFT);
P.setCharSpacing(1);
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayScroll(label.c_str(), PA_LEFT, actualScrollDirection, GENERAL_SCROLL_SPEED);
// --- THIS IS THE BLOCKING LOOP THAT REMAINS PER YOUR REQUEST ---
while (!P.displayAnimate()) {
yield();
}
countdownSegment++;
segmentStartTime = millis();
break;
}
case 4: // Exit countdown
Serial.println("[COUNTDOWN-STATIC] All countdown segments and label displayed. Advancing to Clock.");
countdownSegment = 0;
segmentStartTime = 0;
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
advanceDisplayMode();
yield();
return;
default:
Serial.println("[COUNTDOWN-ERROR] Invalid countdownSegment, resetting.");
countdownSegment = 0;
segmentStartTime = 0;
break;
}
if (currentSegmentText.length() > 0) {
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
P.print(currentSegmentText.c_str());
}
}
P.displayAnimate(); // This handles regular segment display updates
} // End of 'else' (Normal Countdown Segments)
// Keep alignment reset just in case
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
yield();
return;
} // End of if (displayMode == 3 && ...)
yield();
}