diff --git a/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino b/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino new file mode 100644 index 0000000..c145b1d --- /dev/null +++ b/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino @@ -0,0 +1,1910 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 9 +#define CS_PIN 11 +#define DATA_PIN 12 + +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(); + 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()) { + strcpy(countdownLabel, ""); + } else { + const char *labelTemp = labelVariant.as(); + 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("[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; + + auto 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++; + } + delay(1); + } +} + +// ----------------------------------------------------------------------------- +// 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; + + size_t total = LittleFS.totalBytes(); + size_t used = LittleFS.usedBytes(); + Serial.printf("[SAVE] LittleFS total bytes: %llu, used bytes: %llu\n", LittleFS.totalBytes(), LittleFS.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.print(F("[WEATHER] URL: ")); // Use F() with Serial.print + Serial.println(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.print(F("[WEATHER] Payload: ")); // Use F() with Serial.print + Serial.println(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()) { + JsonObject weatherObj = doc[F("weather")][0]; + if (weatherObj.containsKey(F("main"))) { + mainDesc = weatherObj[F("main")].as(); + } + if (weatherObj.containsKey(F("description"))) { + detailedDesc = weatherObj[F("description")].as(); + } + } 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(true)) { + 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() ? doc["countdown"].as() : 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(); +} \ No newline at end of file diff --git a/data/config.json b/ESPTimeCast_ESP32/data/config.json similarity index 100% rename from data/config.json rename to ESPTimeCast_ESP32/data/config.json diff --git a/data/index.html b/ESPTimeCast_ESP32/data/index.html similarity index 100% rename from data/index.html rename to ESPTimeCast_ESP32/data/index.html diff --git a/days_lookup.h b/ESPTimeCast_ESP32/days_lookup.h similarity index 100% rename from days_lookup.h rename to ESPTimeCast_ESP32/days_lookup.h diff --git a/mfactoryfont.h b/ESPTimeCast_ESP32/mfactoryfont.h similarity index 100% rename from mfactoryfont.h rename to ESPTimeCast_ESP32/mfactoryfont.h diff --git a/tz_lookup.h b/ESPTimeCast_ESP32/tz_lookup.h similarity index 100% rename from tz_lookup.h rename to ESPTimeCast_ESP32/tz_lookup.h diff --git a/ESPTimeCast.ino b/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino similarity index 100% rename from ESPTimeCast.ino rename to ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino diff --git a/ESPTimeCast_ESP8266/data/config.json b/ESPTimeCast_ESP8266/data/config.json new file mode 100644 index 0000000..2535637 --- /dev/null +++ b/ESPTimeCast_ESP8266/data/config.json @@ -0,0 +1,31 @@ +{ + "ssid": "", + "password": "", + "openWeatherApiKey": "", + "openWeatherCity": "", + "openWeatherCountry": "", + "clockDuration": 10000, + "weatherDuration": 5000, + "timeZone": "", + "weatherUnits": "metric", + "brightness": 10, + "flipDisplay": false, + "ntpServer1": "pool.ntp.org", + "ntpServer2": "time.nist.gov", + "twelveHourToggle": false, + "showDayOfWeek": true, + "showHumidity": false, + "language": "en", + "dimmingEnabled": false, + "dimStartHour": 18, + "dimStartMinute": 0, + "dimEndHour": 8, + "dimEndMinute": 0, + "dimBrightness": 2, + "showWeatherDescription": false, + "countdown": { + "enabled": false, + "targetTimestamp": 0, + "label": "" + } +} \ No newline at end of file diff --git a/ESPTimeCast_ESP8266/data/index.html b/ESPTimeCast_ESP8266/data/index.html new file mode 100644 index 0000000..0ce9124 --- /dev/null +++ b/ESPTimeCast_ESP8266/data/index.html @@ -0,0 +1,1180 @@ + + + + + +ESPTimeCast Settings + + + + +
+ +

WiFi Settings

+ + + +
+ + +
+

Weather Settings

+ + +
Required to fetch weather data. Get your API key here.
+ + +
+ + +
+ + +
+ Location format examples: City, Country Code - Osaka, JP | ZIP,Country Code - 94040, US | Latitude, Longitude - 34.6937, 135.5023 +
+ + +

Clock Settings

+ + + + + + + +
+
+ + + +
+
+ + + +
+
+ + + +
+ + + +
+ + + \ No newline at end of file diff --git a/ESPTimeCast_ESP8266/days_lookup.h b/ESPTimeCast_ESP8266/days_lookup.h new file mode 100644 index 0000000..7b5dd7e --- /dev/null +++ b/ESPTimeCast_ESP8266/days_lookup.h @@ -0,0 +1,49 @@ +#ifndef DAYS_LOOKUP_H +#define DAYS_LOOKUP_H + +typedef struct { + const char* lang; + const char* days[7]; // Sunday to Saturday (tm_wday order) +} DaysOfWeekMapping; + +const DaysOfWeekMapping days_mappings[] = { + { "af", { "s&u&n", "m&a&a", "d&i&n", "w&o&e", "d&o&n", "v&r&y", "s&o&n" } }, + { "cs", { "n&e&d", "p&o&n", "u&t&e", "s&t&r", "c&t&v", "p&a&t", "s&o&b" } }, + { "da", { "s&o&n", "m&a&n", "t&i&r", "o&n&s", "t&o&r", "f&r&e", "l&o&r" } }, + { "de", { "s&o&n", "m&o&n", "d&i&e", "m&i&t", "d&o&n", "f&r&e", "s&a&m" } }, + { "en", { "s&u&n", "m&o&n", "t&u&e", "w&e&d", "t&h&u", "f&r&i", "s&a&t" } }, + { "eo", { "d&i&m", "l&u&n", "m&a&r", "m&e&r", "j&a&u", "v&e&n", "s&a&b" } }, + { "es", { "d&o&m", "l&u&n", "m&a&r", "m&i&e", "j&u&e", "v&i&e", "s&a&b" } }, + { "et", { "p&a", "e&s", "t&e", "k&o", "n&e", "r&e", "l&a" } }, + { "fi", { "s&u&n", "m&a&a", "t&i&s", "k&e&s", "t&o&r", "p&e&r", "l&a&u" } }, + { "fr", { "d&i&m", "l&u&n", "m&a&r", "m&e&r", "j&e&u", "v&e&n", "s&a&m" } }, + { "hr", { "n&e&d", "p&o&n", "u&t&o", "s&r&i", "c&e&t", "p&e&t", "s&u&b" } }, + { "hu", { "v&a&s", "h&e&t", "k&e&d", "s&z&e", "c&s&u", "p&e&t", "s&z&o" } }, + { "it", { "d&o&m", "l&u&n", "m&a&r", "m&e&r", "g&i&o", "v&e&n", "s&a&b" } }, + { "ja", { "±", "²", "³", "´", "µ", "¶", "·" } }, + { "lt", { "s&e&k", "p&i&r", "a&n&t", "t&r&e", "k&e&t", "p&e&n", "s&e&s" } }, + { "lv", { "s&v&e", "p&i&r", "o&t&r", "t&r&e", "c&e&t", "p&i&e", "s&e&s" } }, + { "nl", { "z&o&n", "m&a&a", "d&i&n", "w&o&e", "d&o&n", "v&r&i", "z&a&t" } }, + { "no", { "s&o&n", "m&a&n", "t&i&r", "o&n&s", "t&o&r", "f&r&e", "l&o&r" } }, + { "pl", { "n&i&e", "p&o&n", "w&t&o", "s&r&o", "c&z&w", "p&i&a", "s&o&b" } }, + { "pt", { "d&o&m", "s&e&g", "t&e&r", "q&u&a", "q&u&i", "s&e&x", "s&a&b" } }, + { "ro", { "d&u&m", "l&u&n", "m&a&r", "m&i&e", "j&o&i", "v&i&n", "s&a&m" } }, + { "sk", { "n&e&d", "p&o&n", "u&t&o", "s&t&r", "s&t&v", "p&i&a", "s&o&b" } }, + { "sl", { "n&e&d", "p&o&n", "t&o&r", "s&r&e", "c&e&t", "p&e&t", "s&o&b" } }, + { "sv", { "s&o&n", "m&a&n", "t&i&s", "o&n&s", "t&o&r", "f&r&e", "l&o&r" } }, + { "sw", { "j&p&l", "j&u&m", "j&t&t", "j&t&n", "a&l&k", "i&j&m", "j&m&s" } }, + { "tr", { "p&a&z", "p&a&z", "s&a&l", "c&a&r", "p&e&r", "c&u&m", "c&u&m" } } +}; + +#define DAYS_MAPPINGS_COUNT (sizeof(days_mappings)/sizeof(days_mappings[0])) + +inline const char* const* getDaysOfWeek(const char* lang) { + for (size_t i = 0; i < DAYS_MAPPINGS_COUNT; i++) { + if (strcmp(lang, days_mappings[i].lang) == 0) + return days_mappings[i].days; + } + // fallback to English if not found + return days_mappings[4].days; // "en" is index 4 +} + +#endif // DAYS_LOOKUP_H \ No newline at end of file diff --git a/ESPTimeCast_ESP8266/mfactoryfont.h b/ESPTimeCast_ESP8266/mfactoryfont.h new file mode 100644 index 0000000..48f5fa3 --- /dev/null +++ b/ESPTimeCast_ESP8266/mfactoryfont.h @@ -0,0 +1,262 @@ +// Data file for user example user defined fonts +#pragma once + +MD_MAX72XX::fontType_t mFactory[] PROGMEM = +{ +1, 0, // 0 - 'Empty Cell' + 1, 0, // 1 - 'Sad Smiley' + 1, 0, // 2 - 'Happy Smiley' + 1, 0, // 3 - 'Heart' + 1, 0, // 4 - 'Diamond' + 1, 0, // 5 - 'Clubs' + 1, 0, // 6 - 'Spades' + 1, 0, // 7 - 'Bullet Point' + 1, 0, // 8 - 'Rev Bullet Point' + 1, 0, // 9 - 'Hollow Bullet Point' + 1, 0, // 10 - 'Rev Hollow BP' + 1, 0, // 11 - 'Male' + 1, 0, // 12 - 'Female' + 1, 0, // 13 - 'Music Note 1' + 1, 0, // 14 - 'Music Note 2' + 1, 0, // 15 - 'Snowflake' + 1, 0, // 16 - 'Right Pointer' + 1, 0, // 17 - 'Left Pointer' + 1, 0, // 18 - 'UpDown Arrows' + 1, 0, // 19 - 'Full Block' + 1, 0, // 20 - 'Half Block Bottom' + 1, 0, // 21 - 'Half Block LHS' + 1, 0, // 22 - 'Half Block RHS' + 1, 0, // 23 - 'Half Block Top' + 1, 0, // 24 - 'Up Arrow' + 1, 0, // 25 - 'Down Arrow' + 1, 0, // 26 - 'Right Arrow' + 1, 0, // 27 - 'Left Arrow' + 1, 0, // 28 - '30% shading' + 1, 0, // 29 - '50% shading' + 1, 0, // 30 - 'Up Pointer' + 1, 0, // 31 - 'Down Pointer' + 1, 0, // 32 - 'Space' + 1, 94, // 33 - '!' + 1, 0, // 34 - '""' + 13, 63, 192, 127, 192, 63, 0, 250, 0, 255, 9, 1, 0, 250, // 35 - '#' + 16, 72, 84, 36, 0, 12, 112, 12, 0, 124, 4, 120, 0, 56, 68, 68, 0, // 36 - '$' + 6, 66, 37, 18, 72, 164, 66, // 37 - '%' + 1, 1, // 38 - '&' + 1, 6, // 39 - '' + 1, 0, // 40 - '(' + 1, 0, // 41 - ')' + 20, 250, 130, 250, 254, 130, 170, 186, 254, 130, 250, 226, 250, 134, 254, 130, 234, 234, 246, 254, 124, // 42 - '*' + 1, 0, // 43 - '+' + 3, 64, 0, 0, // 44 - ',' + 2, 8, 8, // 45 - '-' + 1, 128, // 46 - '.' + 15, 130, 246, 238, 130, 254, 250, 130, 250, 254, 130, 234, 234, 246, 254, 124, // 47 - '/' + 3, 126, 129, 126, // 48 - '0' + 2, 2, 255, // 49 - '1' + 3, 194, 177, 142, // 50 - '2' + 3, 66, 137, 118, // 51 - '3' + 3, 15, 8, 255, // 52 - '4' + 3, 79, 137, 113, // 53 - '5' + 3, 126, 137, 114, // 54 - '6' + 3, 1, 249, 7, // 55 - '7' + 3, 118, 137, 118, // 56 - '8' + 3, 78, 145, 126, // 57 - '9' + 1, 36, // 58 - ':' + 1, 0, // 59 - ';' + 1, 0, // 60 - '<' + 9, 254, 17, 17, 254, 0, 255, 17, 17, 14, // 61 - '=' + 1, 0, // 62 - '>' + 7, 124, 254, 254, 162, 254, 254, 254, // 63 - '?' + 1, 250, // 64 - '@' + 3, 124, 10, 124, // 65 - 'A' + 3, 126, 74, 52, // 66 - 'B' + 3, 60, 66, 66, // 67 - 'C' + 3, 126, 66, 60, // 68 - 'D' + 3, 126, 74, 66, // 69 - 'E' + 3, 126, 10, 2, // 70 - 'F' + 3, 60, 82, 116, // 71 - 'G' + 3, 126, 8, 126, // 72 - 'H' + 1, 126, // 73 - 'I' + 3, 32, 64, 62, // 74 - 'J' + 3, 126, 8, 118, // 75 - 'K' + 3, 126, 64, 64, // 76 - 'L' + 3, 126, 4, 126, // 77 - 'M' + 3, 126, 2, 124, // 78 - 'N' + 3, 60, 66, 60, // 79 - 'O' + 3, 126, 18, 12, // 80 - 'P' + 3, 60, 66, 124, // 81 - 'Q' + 3, 126, 18, 108, // 82 - 'R' + 3, 68, 74, 50, // 83 - 'S' + 3, 2, 126, 2, // 84 - 'T' + 3, 62, 64, 62, // 85 - 'U' + 3, 30, 96, 30, // 86 - 'V' + 3, 126, 32, 126, // 87 - 'W' + 3, 118, 8, 118, // 88 - 'X' + 3, 6, 120, 6, // 89 - 'Y' + 3, 98, 90, 70, // 90 - 'Z' + 4, 126, 129, 129, 66, // 91 - '[' + 3, 6, 28, 48, // 92 - '\' + 4, 255, 9, 9, 1, // 93 - ']' + 1, 8, // 94 - '^' + 3, 32, 32, 32, // 95 - '_' + 4, 255, 8, 20, 227, // 96 - '`' + 3, 249, 21, 249, // 97 - 'a' + 3, 253, 149, 105, // 98 - 'b' + 3, 121, 133, 73, // 99 - 'c' + 3, 253, 133, 121, // 100 - 'd' + 3, 253, 149, 133, // 101 - 'e' + 3, 253, 21, 5, // 102 - 'f' + 3, 121, 165, 233, // 103 - 'g' + 3, 253, 17, 253, // 104 - 'h' + 3, 1, 253, 1, // 105 - 'i' + 3, 65, 129, 125, // 106 - 'j' + 3, 253, 17, 237, // 107 - 'k' + 3, 253, 129, 129, // 108 - 'l' + 3, 253, 9, 253, // 109 - 'm' + 3, 253, 5, 249, // 110 - 'n' + 3, 121, 133, 121, // 111 - 'o' + 3, 253, 37, 25, // 112 - 'p' + 3, 121, 133, 249, // 113 - 'q' + 3, 253, 37, 217, // 114 - 'r' + 3, 137, 149, 101, // 115 - 's' + 3, 5, 253, 5, // 116 - 't' + 3, 125, 129, 125, // 117 - 'u' + 3, 61, 193, 61, // 118 - 'v' + 3, 253, 65, 253, // 119 - 'w' + 3, 237, 17, 237, // 120 - 'x' + 3, 13, 241, 13, // 121 - 'y' + 3, 197, 181, 141, // 122 - 'z' + 16, 255, 253, 129, 253, 255, 129, 255, 129, 251, 129, 255, 129, 181, 189, 255, 249, // 123 - '{' + 16, 255, 187, 181, 205, 255, 255, 193, 191, 193, 255, 129, 237, 243, 255, 161, 255, // 124 - '|' + 16, 0, 2, 126, 2, 0, 126, 0, 126, 4, 126, 0, 126, 74, 66, 0, 6, // 125 - '}' + 16, 0, 68, 74, 50, 0, 0, 62, 64, 62, 0, 126, 18, 12, 0, 94, 0, // 126 - '~' + 0, // 127 - '' + 0, // 128 - '€' + 0, // 129 - '' + 0, // 130 - '‚' + 0, // 131 - 'ƒ' + 0, // 132 - '„' + 0, // 133 - '…' + 0, // 134 - '†' + 0, // 135 - '‡' + 0, // 136 - 'ˆ' + 0, // 137 - '‰' + 0, // 138 - 'Š' + 0, // 139 - '‹' + 0, // 140 - 'Œ' + 0, // 141 - '' + 0, // 142 - 'Ž' + 0, // 143 - '' + 0, // 144 - '' + 2, 4, 126, // 145 - '‘' + 3, 100, 82, 76, // 146 - '’' + 3, 66, 74, 52, // 147 - '“' + 3, 14, 8, 126, // 148 - '”' + 3, 78, 74, 50, // 149 - '•' + 3, 60, 74, 52, // 150 - '–' + 3, 2, 122, 6, // 151 - '—' + 3, 52, 74, 52, // 152 - '˜' + 3, 12, 82, 60, // 153 - '™' + 3, 60, 66, 60, // 154 - 'š' + 0, // 155 - '›' + 0, // 156 - 'œ' + 0, // 157 - '' + 0, // 158 - 'ž' + 0, // 159 - 'Ÿ' + 0, // 160 - '' + 5, 227, 151, 143, 151, 227, // 161 - '¡' + 5, 227, 149, 157, 149, 227, // 162 - '¢' + 5, 227, 181, 185, 181, 227, // 163 - '£' + 5, 227, 245, 249, 245, 227, // 164 - '¤' + 0, // 165 - '¥' + 0, // 166 - '¦' + 0, // 167 - '§' + 0, // 168 - '¨' + 8, 224, 224, 0, 0, 0, 0, 0, 0, // 169 - '©' + 8, 224, 224, 0, 252, 252, 0, 0, 0, // 170 - 'ª' + 8, 224, 224, 0, 252, 252, 0, 255, 255, // 171 - '«' + 0, // 172 - '¬' + 0, // 173 - '­' + 5, 64, 0, 0, 0, 0, // 174 - '®' + 5, 64, 0, 64, 0, 0, // 175 - '¯' + 5, 64, 0, 64, 0, 64, // 176 - '°' + 5, 254, 146, 146, 146, 254, // 177 - '±' + 6, 128, 126, 42, 42, 170, 254, // 178 - '²' + 7, 128, 152, 64, 62, 80, 136, 128, // 179 - '³' + 7, 72, 40, 152, 254, 16, 40, 68, // 180 - '´' + 7, 68, 36, 20, 254, 20, 36, 68, // 181 - 'µ' + 7, 168, 232, 172, 250, 172, 232, 168, // 182 - '¶' + 7, 128, 136, 136, 254, 136, 136, 128, // 183 - '·' + 0, // 184 - '¸' + 0, // 185 - '¹' + 3, 4, 10, 4, // 186 - 'º' + 0, // 187 - '»' + 0, // 188 - '¼' + 0, // 189 - '½' + 0, // 190 - '¾' + 0, // 191 - '¿' + 0, // 192 - 'À' + 0, // 193 - 'Á' + 0, // 194 - 'Â' + 0, // 195 - 'Ã' + 0, // 196 - 'Ä' + 0, // 197 - 'Å' + 0, // 198 - 'Æ' + 0, // 199 - 'Ç' + 0, // 200 - 'È' + 0, // 201 - 'É' + 0, // 202 - 'Ê' + 0, // 203 - 'Ë' + 0, // 204 - 'Ì' + 0, // 205 - 'Í' + 0, // 206 - 'Î' + 0, // 207 - 'Ï' + 0, // 208 - 'Ð' + 0, // 209 - 'Ñ' + 0, // 210 - 'Ò' + 0, // 211 - 'Ó' + 0, // 212 - 'Ô' + 0, // 213 - 'Õ' + 0, // 214 - 'Ö' + 0, // 215 - '×' + 0, // 216 - 'Ø' + 0, // 217 - 'Ù' + 0, // 218 - 'Ú' + 0, // 219 - 'Û' + 0, // 220 - 'Ü' + 0, // 221 - 'Ý' + 0, // 222 - 'Þ' + 0, // 223 - 'ß' + 0, // 224 - 'à' + 0, // 225 - 'á' + 0, // 226 - 'â' + 0, // 227 - 'ã' + 0, // 228 - 'ä' + 0, // 229 - 'å' + 0, // 230 - 'æ' + 0, // 231 - 'ç' + 0, // 232 - 'è' + 0, // 233 - 'é' + 0, // 234 - 'ê' + 0, // 235 - 'ë' + 0, // 236 - 'ì' + 0, // 237 - 'í' + 0, // 238 - 'î' + 0, // 239 - 'ï' + 0, // 240 - 'ð' + 0, // 241 - 'ñ' + 0, // 242 - 'ò' + 0, // 243 - 'ó' + 0, // 244 - 'ô' + 0, // 245 - 'õ' + 0, // 246 - 'ö' + 0, // 247 - '÷' + 0, // 248 - 'ø' + 0, // 249 - 'ù' + 0, // 250 - 'ú' + 0, // 251 - 'û' + 0, // 252 - 'ü' + 0, // 253 - 'ý' + 0, // 254 - 'þ' + 0, // 255 - 'ÿ' +}; diff --git a/ESPTimeCast_ESP8266/tz_lookup.h b/ESPTimeCast_ESP8266/tz_lookup.h new file mode 100644 index 0000000..d529c61 --- /dev/null +++ b/ESPTimeCast_ESP8266/tz_lookup.h @@ -0,0 +1,104 @@ +#ifndef TZ_LOOKUP_H +#define TZ_LOOKUP_H + +typedef struct { + const char* iana; + const char* posix; +} TimeZoneMapping; + +const TimeZoneMapping tz_mappings[] = { + {"Africa/Cairo", "EET-2EEST,M4.5.5/0,M10.5.5/0"}, + {"Africa/Casablanca", "WET0WEST,M3.5.0/0,M10.5.0/0"}, + {"Africa/Johannesburg", "SAST-2"}, + {"America/Anchorage", "AKST9AKDT,M3.2.0,M11.1.0"}, + {"America/Argentina/Buenos_Aires", "ART3"}, + {"America/Chicago", "CST6CDT,M3.2.0,M11.1.0"}, + {"America/Denver", "MST7MDT,M3.2.0,M11.1.0"}, + {"America/Guatemala", "CST6"}, + {"America/Halifax", "AST4ADT,M3.2.0,M11.1.0"}, + {"America/Los_Angeles", "PST8PDT,M3.2.0,M11.1.0"}, + {"America/Mexico_City", "CST6CDT,M4.1.0,M10.5.0"}, + {"America/New_York", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Phoenix", "MST7"}, + {"America/Santiago", "CLT4CLST,M9.1.6/24,M4.1.6/24"}, + {"America/Sao_Paulo", "BRT3"}, + {"America/St_Johns", "NST3:30NDT,M3.2.0,M11.1.0"}, + {"America/Toronto", "EST5EDT,M3.2.0,M11.1.0"}, + {"America/Vancouver", "PST8PDT,M3.2.0,M11.1.0"}, + {"Asia/Almaty", "ALMT-6"}, + {"Asia/Amman", "EET-2EEST,M3.5.4/24,M10.5.5/1"}, + {"Asia/Baghdad", "AST-3"}, + {"Asia/Bangkok", "ICT-7"}, + {"Asia/Beirut", "EET-2EEST,M3.5.0/0,M10.5.0/0"}, + {"Asia/Dhaka", "BDT-6"}, + {"Asia/Dubai", "GST-4"}, + {"Asia/Ho_Chi_Minh", "ICT-7"}, + {"Asia/Hong_Kong", "HKT-8"}, + {"Asia/Jakarta", "WIB-7"}, + {"Asia/Jerusalem", "IST-2IDT,M3.4.4/26,M10.5.0"}, + {"Asia/Karachi", "PKT-5"}, + {"Asia/Kathmandu", "NPT-5:45"}, + {"Asia/Kolkata", "IST-5:30"}, + {"Asia/Kuala_Lumpur", "MYT-8"}, + {"Asia/Manila", "PST-8"}, + {"Asia/Seoul", "KST-9"}, + {"Asia/Shanghai", "CST-8"}, + {"Asia/Singapore", "SGT-8"}, + {"Asia/Taipei", "CST-8"}, + {"Asia/Tashkent", "UZT-5"}, + {"Asia/Tokyo", "JST-9"}, + {"Asia/Ulaanbaatar", "ULAT-8"}, + {"Asia/Yekaterinburg", "YEKT-5"}, + {"Atlantic/Azores", "AZOT1AZOST,M3.5.0/0,M10.5.0/0"}, + {"Atlantic/Reykjavik", "GMT0"}, + {"Australia/Adelaide", "ACST-9:30ACDT,M10.1.0,M4.1.0"}, + {"Australia/Brisbane", "AEST-10"}, + {"Australia/Darwin", "ACST-9:30"}, + {"Australia/Hobart", "AEST-10AEDT,M10.1.0,M4.1.0"}, + {"Australia/Melbourne", "AEST-10AEDT,M10.1.0,M4.1.0"}, + {"Australia/Perth", "AWST-8"}, + {"Australia/Sydney", "AEST-10AEDT,M10.1.0,M4.1.0"}, + {"Europe/Amsterdam", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Athens", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Belgrade", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Berlin", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Bucharest", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Copenhagen", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Dublin", "GMT0IST,M3.5.0/1,M10.5.0"}, + {"Europe/Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Istanbul", "TRT-3"}, + {"Europe/Kiev", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Lisbon", "WET0WEST,M3.5.0/0,M10.5.0/0"}, + {"Europe/London", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Madrid", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Moscow", "MSK-3"}, + {"Europe/Oslo", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Paris", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Prague", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Rome", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Stockholm", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Warsaw", "CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Pacific/Auckland", "NZST-12NZDT,M9.5.0,M4.1.0"}, + {"Pacific/Chatham", "CHAST-12:45CHADT,M9.5.0,M4.1.0/3"}, + {"Pacific/Fiji", "FJT-12"}, + {"Pacific/Guam", "ChST-10"}, + {"Pacific/Honolulu", "HST10"}, + {"Pacific/Port_Moresby", "PGT-10"}, + {"Pacific/Tahiti", "TAHT10"}, + {"UTC", "UTC0"}, + {"Etc/GMT+1", "GMT-1"}, + {"Etc/GMT-1", "GMT+1"} +}; + +#define TZ_MAPPINGS_COUNT (sizeof(tz_mappings)/sizeof(tz_mappings[0])) + +inline const char* ianaToPosix(const char* iana) { + for (size_t i = 0; i < TZ_MAPPINGS_COUNT; i++) { + if (strcmp(iana, tz_mappings[i].iana) == 0) + return tz_mappings[i].posix; + } + return "UTC0"; // fallback +} + +#endif // TZ_LOOKUP_H \ No newline at end of file