Files
ESPTimeCast/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino
2026-02-19 17:30:32 +09:00

3999 lines
145 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 <WiFiClientSecure.h>
#include <ESP8266mDNS.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
#include "months_lookup.h" // Languages for the Months of the Year
#include "index_html.h" // Web UI
#define FIRMWARE_VERSION "1.0.1"
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 4
#define CLK_PIN 14 //D5
#define CS_PIN 13 //D7
#define DATA_PIN 15 //D8
#ifdef ESP8266
WiFiEventHandler mConnectHandler;
WiFiEventHandler mDisConnectHandler;
WiFiEventHandler mGotIpHandler;
#endif
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)
int messageScrollSpeed = 85; // default fallback
// --- Nightscout setting ---
const unsigned int NIGHTSCOUT_IDLE_THRESHOLD_MIN = 10; // minutes before data is considered outdated
// --- Device identity ---
const char *DEFAULT_HOSTNAME = "esptimecast";
const char *DEFAULT_AP_PASSWORD = "12345678";
const char *DEFAULT_AP_SSID = "ESPTimeCast";
String deviceHostname = DEFAULT_HOSTNAME;
// WiFi and configuration globals
char ssid[32] = "";
char password[64] = "";
char openWeatherApiKey[64] = "";
char openWeatherCity[64] = "";
char openWeatherCountry[64] = "";
char weatherUnits[12] = "metric";
char timeZone[64] = "";
char language[8] = "en";
unsigned long lastWifiConnectTime = 0;
String mainDesc = "";
String detailedDesc = "";
// Timing and display settings
unsigned long clockDuration = 10000;
unsigned long weatherDuration = 5000;
bool displayOff = false;
int brightness = 7;
bool flipDisplay = false;
bool twelveHourToggle = false;
bool showDayOfWeek = true;
bool showDate = false;
bool showHumidity = false;
bool colonBlinkEnabled = true;
char ntpServer1[64] = "pool.ntp.org";
char ntpServer2[256] = "time.nist.gov";
char customMessage[121] = "";
char lastPersistentMessage[128] = "";
int messageDisplaySeconds;
int messageScrollTimes;
unsigned long messageStartTime = 0;
int currentScrollCount = 0;
int currentDisplayCycleCount = 0;
// Dimming
bool dimmingEnabled = false;
bool displayOffByDimming = false;
bool displayOffByBrightness = false;
int dimStartHour = 18; // 6pm default
int dimStartMinute = 0;
int dimEndHour = 8; // 8am default
int dimEndMinute = 0;
int dimBrightness = 2; // Dimming level (0-15)
bool autoDimmingEnabled = false; // true if using sunrise/sunset
int sunriseHour = 6;
int sunriseMinute = 0;
int sunsetHour = 18;
int sunsetMinute = 0;
bool clockOnlyDuringDimming = false;
//Countdown Globals - NEW
bool countdownEnabled = false;
time_t countdownTargetTimestamp = 0; // Unix timestamp
char countdownLabel[64] = ""; // Label for the countdown
bool isDramaticCountdown = true; // Default to the dramatic countdown mode
// Runtime Uptime Tracker
unsigned long bootMillis = 0; // Stores millis() at boot
unsigned long lastUptimeLog = 0; // Timer for hourly logging
const unsigned long uptimeLogInterval = 600000UL; // 10 minutes in ms
unsigned long totalUptimeSeconds = 0; // Persistent accumulated uptime in seconds
// 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 prevDisplayMode = -1;
bool clockScrollDone = false;
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 = 1; // 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
// --- Safe WiFi credential and API getters ---
const char *getSafeSsid() {
if (isAPMode && strlen(ssid) == 0) {
return "";
} else {
return isAPMode ? "********" : ssid;
}
}
const char *getSafePassword() {
if (strlen(password) == 0) { // No password set yet — return empty string for fresh install
return "";
} else { // Password exists — mask it in the web UI
return "********";
}
}
const char *getSafeApiKey() {
if (strlen(openWeatherApiKey) == 0) {
return "";
} else {
return "********************************"; // Always masked, even in AP mode
}
}
// 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("showDate")] = false;
doc[F("showHumidity")] = showHumidity;
doc[F("colonBlinkEnabled")] = colonBlinkEnabled;
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;
// --- Automatic dimming defaults ---
doc[F("autoDimmingEnabled")] = autoDimmingEnabled;
doc[F("sunriseHour")] = sunriseHour;
doc[F("sunriseMinute")] = sunriseMinute;
doc[F("sunsetHour")] = sunsetHour;
doc[F("sunsetMinute")] = sunsetMinute;
doc[F("clockOnlyDuringDimming")] = false;
// Add countdown defaults when creating a new config.json
JsonObject countdownObj = doc.createNestedObject("countdown");
countdownObj["enabled"] = false;
countdownObj["targetTimestamp"] = 0;
countdownObj["label"] = "";
countdownObj["isDramaticCountdown"] = true;
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;
}
bool configChanged = false;
strlcpy(ssid, doc["ssid"] | "", sizeof(ssid));
strlcpy(password, doc["password"] | "", sizeof(password));
strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey));
strlcpy(openWeatherCity, doc["openWeatherCity"] | "", sizeof(openWeatherCity));
strlcpy(openWeatherCountry, doc["openWeatherCountry"] | "", sizeof(openWeatherCountry));
strlcpy(weatherUnits, doc["weatherUnits"] | "metric", sizeof(weatherUnits));
strlcpy(customMessage, doc["customMessage"] | "", sizeof(customMessage));
strlcpy(lastPersistentMessage, customMessage, sizeof(lastPersistentMessage));
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;
showDate = doc["showDate"] | false;
showHumidity = doc["showHumidity"] | false;
colonBlinkEnabled = doc.containsKey("colonBlinkEnabled") ? doc["colonBlinkEnabled"].as<bool>() : true;
showWeatherDescription = doc["showWeatherDescription"] | false;
// --- Dimming settings ---
if (doc["dimmingEnabled"].is<bool>()) {
dimmingEnabled = doc["dimmingEnabled"].as<bool>();
} else {
String de = doc["dimmingEnabled"].as<String>();
dimmingEnabled = (de == "true" || de == "1" || de == "on");
}
dimStartHour = doc["dimStartHour"] | 18;
dimStartMinute = doc["dimStartMinute"] | 0;
dimEndHour = doc["dimEndHour"] | 8;
dimEndMinute = doc["dimEndMinute"] | 0;
// safely handle both numeric or string "Off" for dimBrightness
if (doc["dimBrightness"].is<int>()) {
dimBrightness = doc["dimBrightness"].as<int>();
} else {
String val = doc["dimBrightness"].as<String>();
if (val.equalsIgnoreCase("off")) dimBrightness = -1;
else dimBrightness = val.toInt();
}
// --- Automatic dimming ---
if (doc.containsKey("autoDimmingEnabled")) {
if (doc["autoDimmingEnabled"].is<bool>()) {
autoDimmingEnabled = doc["autoDimmingEnabled"].as<bool>();
} else {
String val = doc["autoDimmingEnabled"].as<String>();
autoDimmingEnabled = (val == "true" || val == "1" || val == "on");
}
} else {
autoDimmingEnabled = false; // default if key missing
}
sunriseHour = doc["sunriseHour"] | 6;
sunriseMinute = doc["sunriseMinute"] | 0;
sunsetHour = doc["sunsetHour"] | 18;
sunsetMinute = doc["sunsetMinute"] | 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 = '[';
// --- COUNTDOWN CONFIG LOADING ---
if (doc.containsKey("countdown")) {
JsonObject countdownObj = doc["countdown"];
countdownEnabled = countdownObj["enabled"] | false;
countdownTargetTimestamp = countdownObj["targetTimestamp"] | 0;
isDramaticCountdown = countdownObj["isDramaticCountdown"] | true;
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, "");
isDramaticCountdown = true;
Serial.println(F("[CONFIG] Countdown object not found, defaulting to disabled."));
countdownFinished = false;
}
// --- CLOCK-ONLY-DURING-DIMMING LOADING ---
if (doc.containsKey("clockOnlyDuringDimming")) {
clockOnlyDuringDimming = doc["clockOnlyDuringDimming"].as<bool>();
} else {
clockOnlyDuringDimming = false;
doc["clockOnlyDuringDimming"] = clockOnlyDuringDimming;
configChanged = true;
Serial.println(F("[CONFIG] Migrated: added clockOnlyDuringDimming default."));
}
// --- Save migrated config if needed ---
if (configChanged) {
Serial.println(F("[CONFIG] Saving migrated config.json"));
File f = LittleFS.open("/config.json", "w");
if (f) {
serializeJsonPretty(doc, f);
f.close();
Serial.println(F("[CONFIG] Migration saved successfully."));
} else {
Serial.println(F("[ERROR] Failed to save migrated config.json"));
}
}
Serial.println(F("[CONFIG] Configuration loaded."));
}
// -----------------------------------------------------------------------------
// Network Identity
// -----------------------------------------------------------------------------
void setupHostname() {
#if defined(ESP8266)
WiFi.hostname(deviceHostname);
#elif defined(ESP32)
WiFi.setHostname(deviceHostname.c_str());
#endif
}
// -----------------------------------------------------------------------------
// WiFi Setup
// -----------------------------------------------------------------------------
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);
setupHostname();
if (strlen(DEFAULT_AP_PASSWORD) < 8) {
WiFi.softAP(DEFAULT_AP_SSID);
Serial.println(F("[WIFI] AP Mode started (no password, too short)."));
} else {
WiFi.softAP(DEFAULT_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.persistent(false);
WiFi.mode(WIFI_STA);
WiFi.disconnect(true);
delay(100);
setupHostname();
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();
// Replace all dots with your custom font code 184
for (int i = 0; i < pendingIpToShow.length(); i++) {
if (pendingIpToShow[i] == '.') {
pendingIpToShow[i] = 184;
}
}
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(DEFAULT_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();
}
}
// -----------------------------------------------------------------------------
// mDNS
// -----------------------------------------------------------------------------
void setupMDNS() {
MDNS.end();
bool mdnsStarted = MDNS.begin(deviceHostname.c_str());
if (mdnsStarted) {
MDNS.addService("http", "tcp", 80);
Serial.printf("[WIFI] mDNS started: http://%s.local\n", deviceHostname.c_str());
} else {
Serial.println("[WIFI] mDNS failed to start");
}
}
// -----------------------------------------------------------------------------
// Time / NTP Functions
// -----------------------------------------------------------------------------
void setupTime() {
sntp_stop();
if (!isAPMode) {
Serial.println(F("[TIME] Starting NTP sync"));
}
configTime(0, 0, ntpServer1, ntpServer2);
// Set the Time Zone
setenv("TZ", ianaToPosix(timeZone), 1);
tzset();
// Initialize state flags (essential for your loop logic to handle retries)
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 Date: "));
Serial.println(showDate ? "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("Blinking colon: "));
Serial.println(colonBlinkEnabled ? "Yes" : "No");
Serial.print(F("NTP Server 1: "));
Serial.println(ntpServer1);
Serial.print(F("NTP Server 2: "));
Serial.println(ntpServer2);
// ---------------------------------------------------------------------------
// DIMMING SECTION
// ---------------------------------------------------------------------------
Serial.print(F("Automatic Dimming: "));
Serial.println(autoDimmingEnabled ? "Enabled" : "Disabled");
Serial.print(F("Custom Dimming: "));
Serial.println(dimmingEnabled ? "Enabled" : "Disabled");
Serial.print(F("Clock only during dimming: "));
Serial.println(clockOnlyDuringDimming ? "Yes" : "No");
if (autoDimmingEnabled) {
// --- Automatic (Sunrise/Sunset) dimming mode ---
if ((sunriseHour == 6 && sunriseMinute == 0) && (sunsetHour == 18 && sunsetMinute == 0)) {
Serial.println(F("Automatic Dimming Schedule: Sunrise/Sunset Data not available yet (waiting for weather update)"));
} else {
Serial.printf("Automatic Dimming Schedule: Sunrise: %02d:%02d → Sunset: %02d:%02d\n",
sunriseHour, sunriseMinute, sunsetHour, sunsetMinute);
time_t now_time = time(nullptr);
struct tm localTime;
localtime_r(&now_time, &localTime);
int curTotal = localTime.tm_hour * 60 + localTime.tm_min;
int startTotal = sunsetHour * 60 + sunsetMinute;
int endTotal = sunriseHour * 60 + sunriseMinute;
bool autoActive = (startTotal < endTotal)
? (curTotal >= startTotal && curTotal < endTotal)
: (curTotal >= startTotal || curTotal < endTotal);
Serial.printf("Current Auto-Dimming Status: %s\n", autoActive ? "ACTIVE" : "Inactive");
Serial.printf("Dimming Brightness (night): %d\n", dimBrightness);
}
} else {
// --- Manual (Custom Schedule) dimming mode ---
Serial.printf("Custom Dimming Schedule: %02d:%02d → %02d:%02d\n",
dimStartHour, dimStartMinute, dimEndHour, dimEndMinute);
Serial.printf("Dimming Brightness: %d\n", 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.print(F("Dramatic Countdown Display: "));
Serial.println(isDramaticCountdown ? "Yes" : "No");
Serial.print(F("Custom Message: "));
Serial.println(customMessage);
Serial.print(F("Total Runtime: "));
if (getTotalRuntimeSeconds() > 0) {
Serial.println(formatTotalRuntime());
} else {
Serial.println(F("No runtime recorded yet."));
}
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: /"));
// Create a response from LittleFS file so we can attach cache-control headers
AsyncWebServerResponse *response = request->beginResponse(LittleFS, "/index.html", "text/html");
response->addHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response->addHeader("Pragma", "no-cache");
response->addHeader("Expires", "0");
request->send(response);
});
server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(204); // 204 No Content response
});
server.on("/apple-touch-icon.png", HTTP_GET, [](AsyncWebServerRequest *request) { // iOS icon check
request->send(204);
});
server.on("/gen_204", HTTP_GET, [](AsyncWebServerRequest *request) { // Android short probe (already in handleCaptivePortal, but safe to also silence if somehow missed)
request->send(204);
});
server.on("/library/test/success.html", HTTP_GET, [](AsyncWebServerRequest *request) { // iOS/macOS generic check
request->send(204);
});
server.on("/connecttest.txt", HTTP_GET, [](AsyncWebServerRequest *request) { // Windows NCSI check
request->send(204);
});
server.on("/msdownload/update/v3/static/trustedr/en/disallowedcertstl.cab", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(204);
});
server.on("/msdownload/update/v3/static/trustedr/en/authrootstl.cab", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(204);
});
server.on("/msdownload/update/v3/static/trustedr/en/pinrulesstl.cab", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(204);
});
server.on("/r/r1.crl", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(204);
});
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;
}
// Always sanitize before sending to browser
doc[F("ssid")] = getSafeSsid();
doc[F("password")] = getSafePassword();
doc[F("openWeatherApiKey")] = getSafeApiKey();
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 == "showDate") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "showHumidity") doc[n] = (v == "true" || v == "on" || v == "1");
else if (n == "colonBlinkEnabled") 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") {
if (v == "Off" || v == "off") doc[n] = -1;
else 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 == "clockOnlyDuringDimming") {
doc[n] = (v == "true" || v == "on" || v == "1");
} else if (n == "weatherUnits") doc[n] = v;
else if (n == "password") {
if (v != "********" && v.length() > 0) {
doc[n] = v; // user entered a new password
} else {
Serial.println(F("[SAVE] Password unchanged."));
// do nothing, keep the one already in doc
}
} else if (n == "ssid") {
if (v != "********" && v.length() > 0) {
doc[n] = v;
} else {
Serial.println(F("[SAVE] SSID unchanged."));
}
} else if (n == "openWeatherApiKey") {
if (v != "********************************") { // ignore mask only
doc[n] = v; // save new key (even if empty)
Serial.print(F("[SAVE] API key updated: "));
Serial.println(v.length() == 0 ? "(empty)" : v);
} else {
Serial.println(F("[SAVE] API key unchanged (mask ignored)."));
}
} 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() : "";
bool newIsDramaticCountdown = (request->hasParam("isDramaticCountdown", true) && (request->getParam("isDramaticCountdown", true)->value() == "true" || request->getParam("isDramaticCountdown", true)->value() == "on" || request->getParam("isDramaticCountdown", true)->value() == "1"));
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;
countdownObj["isDramaticCountdown"] = newIsDramaticCountdown;
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);
strlcpy(customMessage, doc["customMessage"] | "", sizeof(customMessage));
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..."));
saveUptime();
delay(100); // ensure file is written
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..."));
saveUptime();
delay(100); // ensure file is written
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;
}
String sourceHeader = request->header("X-Source");
bool isFromUI = (sourceHeader == "UI");
bool isFromHA = !isFromUI;
int newBrightness = request->getParam("value", true)->value().toInt();
// Handle OFF request
if (newBrightness == -1) {
P.displayShutdown(true);
P.displayClear();
displayOff = true;
Serial.printf("[BRIGHTNESS] Display OFF via %s\n",
isFromUI ? "UI" : "HA");
request->send(200, "application/json", "{\"ok\":true, \"display\":\"off\"}");
return;
}
// Clamp brightness range (015)
newBrightness = constrain(newBrightness, 0, 15);
if (displayOff) {
// Wake from OFF
P.setIntensity(newBrightness);
advanceDisplayModeSafe();
P.displayShutdown(false);
brightness = newBrightness;
displayOff = false;
Serial.printf("[BRIGHTNESS] Display woke from OFF via %s → %d\n",
isFromUI ? "UI" : "HA",
newBrightness);
} else {
// Display already ON
brightness = newBrightness;
P.setIntensity(brightness);
Serial.printf("[BRIGHTNESS] Set to %d via %s\n",
brightness,
isFromUI ? "UI" : "HA");
}
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_showdate", HTTP_POST, [](AsyncWebServerRequest *request) {
bool showDateVal = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
showDateVal = (v == "1" || v == "true" || v == "on");
}
showDate = showDateVal;
Serial.printf("[WEBSERVER] Set showDate to %d\n", showDate);
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_colon_blink", HTTP_POST, [](AsyncWebServerRequest *request) {
bool enableBlink = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
enableBlink = (v == "1" || v == "true" || v == "on");
}
colonBlinkEnabled = enableBlink;
Serial.printf("[WEBSERVER] Set colonBlinkEnabled to %d\n", colonBlinkEnabled);
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();
lang.trim(); // Remove whitespace/newlines
lang.toLowerCase(); // Normalize to lowercase
strlcpy(language, lang.c_str(), sizeof(language)); // Safe copy to char[]
Serial.printf("[WEBSERVER] Set language to '%s'\n", language); // Use quotes for debug
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.on("/set_dramatic_countdown", HTTP_POST, [](AsyncWebServerRequest *request) {
bool enableDramaticNow = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
enableDramaticNow = (v == "1" || v == "true" || v == "on");
}
// Check if the state has changed
if (isDramaticCountdown == enableDramaticNow) {
Serial.println(F("[WEBSERVER] Dramatic Countdown state unchanged, ignoring."));
request->send(200, "application/json", "{\"ok\":true}");
return;
}
// Update the global variable
isDramaticCountdown = enableDramaticNow;
// Call saveCountdownConfig with only the existing parameters.
// It will read the updated global variable 'isDramaticCountdown'.
saveCountdownConfig(countdownEnabled, countdownTargetTimestamp, countdownLabel);
Serial.printf("[WEBSERVER] Set Dramatic Countdown to %d\n", isDramaticCountdown);
request->send(200, "application/json", "{\"ok\":true}");
});
// Set Clock-only-during-dimming (no reboot)
server.on("/set_clock_only_dimming", HTTP_POST, [](AsyncWebServerRequest *request) {
bool enableNow = false;
if (request->hasParam("value", true)) {
String v = request->getParam("value", true)->value();
enableNow = (v == "1" || v == "true" || v == "on");
}
// Update runtime variable immediately
clockOnlyDuringDimming = enableNow;
Serial.printf("[WEBSERVER] Set clockOnlyDuringDimming to %d (requested)\n", clockOnlyDuringDimming);
// Read existing config.json (if present)
DynamicJsonDocument doc(2048);
bool needToWrite = true;
File configFile = LittleFS.open("/config.json", "r");
if (configFile) {
DeserializationError err = deserializeJson(doc, configFile);
configFile.close();
if (err) {
Serial.print(F("[WEBSERVER] Error parsing existing config.json: "));
Serial.println(err.f_str());
// proceed to write (will create a new doc)
doc.clear();
} else {
// If the key exists and matches the requested value, skip write
bool existing = doc["clockOnlyDuringDimming"] | false;
if (existing == enableNow) {
Serial.println(F("[WEBSERVER] clockOnlyDuringDimming unchanged — skipping write."));
// Send immediate OK response without touching FS
DynamicJsonDocument okDoc(128);
okDoc[F("ok")] = true;
okDoc[F("clockOnlyDuringDimming")] = enableNow;
String response;
serializeJson(okDoc, response);
request->send(200, "application/json", response);
return;
}
}
} else {
// No config file found — doc is empty and we will write
doc.clear();
}
// Set/update the key in the JSON doc
doc[F("clockOnlyDuringDimming")] = clockOnlyDuringDimming;
// Backup existing file only if it exists (and only because we're about to replace it)
if (LittleFS.exists("/config.json")) {
if (!LittleFS.rename("/config.json", "/config.bak")) {
Serial.println(F("[WEBSERVER] Warning: failed to create config backup"));
// continue anyway
}
}
File f = LittleFS.open("/config.json", "w");
if (!f) {
Serial.println(F("[WEBSERVER] ERROR: Failed to open /config.json for writing"));
DynamicJsonDocument errDoc(128);
errDoc[F("error")] = "Failed to write config file.";
String response;
serializeJson(errDoc, response);
request->send(500, "application/json", response);
return;
}
size_t bytesWritten = serializeJson(doc, f);
f.close();
Serial.printf("[WEBSERVER] Saved clockOnlyDuringDimming=%d to /config.json (%u bytes written)\n", clockOnlyDuringDimming, bytesWritten);
// Send immediate response (no reboot)
DynamicJsonDocument okDoc(128);
okDoc[F("ok")] = true;
okDoc[F("clockOnlyDuringDimming")] = clockOnlyDuringDimming;
String response;
serializeJson(okDoc, response);
request->send(200, "application/json", response);
});
// --- Custom Message Endpoint ---
server.on("/set_custom_message", HTTP_POST, [](AsyncWebServerRequest *request) {
if (request->hasParam("message", true)) {
String msg = request->getParam("message", true)->value();
msg.trim();
String sourceHeader = request->header("X-Source");
bool isFromUI = (sourceHeader == "UI");
bool isFromHA = !isFromUI;
messageDisplaySeconds = 0; // Reset
if (request->hasParam("seconds", true)) {
messageDisplaySeconds = constrain(request->getParam("seconds", true)->value().toInt(), 0, 3600); // 1 hour max
}
messageScrollTimes = 0; // Reset
if (request->hasParam("scrolltimes", true)) {
messageScrollTimes = constrain(request->getParam("scrolltimes", true)->value().toInt(), 0, 100); // 100 max scrolls
}
// --- Local speed variable (does not modify global GENERAL_SCROLL_SPEED) ---
int localSpeed = GENERAL_SCROLL_SPEED; // Default for UI messages
if (request->hasParam("speed", true)) {
localSpeed = constrain(request->getParam("speed", true)->value().toInt(), 10, 200);
}
// --- CLEAR MESSAGE ---
if (msg.length() == 0) {
if (isFromUI) {
// Web UI clear: The "real" clear, resets everything.
customMessage[0] = '\0';
lastPersistentMessage[0] = '\0';
displayMode = 0;
messageStartTime = 0;
currentScrollCount = 0;
messageDisplaySeconds = 0;
messageScrollTimes = 0;
Serial.println(F("[MESSAGE] All messages cleared by UI. Returning to normal mode."));
request->send(200, "text/plain", "CLEARED (UI)");
// --- SAVE CLEAR STATE ---
saveCustomMessageToConfig("");
} else {
// HA clear: remove only temporary message, reset time/scroll variables.
customMessage[0] = '\0'; // Clear the currently active message
// Reset the temporary HA timing/scroll limits.
messageStartTime = 0;
currentScrollCount = 0;
messageDisplaySeconds = 0;
messageScrollTimes = 0;
if (strlen(lastPersistentMessage) > 0) {
// Restore the last persistent message
strncpy(customMessage, lastPersistentMessage, sizeof(customMessage));
messageScrollSpeed = GENERAL_SCROLL_SPEED; // Use global speed for persistent
// Ensure displayMode is set to 6 so the restored persistent message is shown immediately.
displayMode = 6;
prevDisplayMode = 0;
Serial.printf("[MESSAGE] Temporary HA message cleared. Restored persistent message: '%s' (speed=%d)\n",
customMessage, messageScrollSpeed);
request->send(200, "text/plain", "CLEARED (HA temporary, persistent restored)");
} else {
// No persistent message to restore, return to clock mode.
displayMode = 0;
Serial.println(F("[MESSAGE] Temporary HA message cleared. No persistent message to restore."));
request->send(200, "text/plain", "CLEARED (HA temporary, no persistent)");
}
}
return;
}
// --- SANITIZE MESSAGE ---
msg.toUpperCase();
String filtered = "";
for (size_t i = 0; i < msg.length(); i++) {
char c = msg[i];
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == ' ' || c == ':' || c == '!' || c == '\'' || c == '-' || c == '.' || c == ',' || c == '_' || c == '+' || c == '%' || c == '/' || c == '?') {
filtered += c;
}
// Check for degree symbol (UTF-8 0xC2 0xB0)
else if ((unsigned char)c == 0xC2 && i + 1 < msg.length() && (unsigned char)msg[i + 1] == 0xB0) {
filtered += "°"; // add single character
i++; // skip next byte
}
}
filtered.toCharArray(customMessage, sizeof(customMessage));
// --- STORE MESSAGE ---
if (isFromHA) {
// --- Only backup if lastPersistentMessage exists ---
if (strlen(lastPersistentMessage) > 0) {
Serial.printf("[HA] Will preserve persistent message: '%s'\n", lastPersistentMessage);
} else {
Serial.println(F("[HA] No persistent message to preserve. HA message is temporary only."));
}
// --- Overwrite customMessage with new temporary HA message ---
filtered.toCharArray(customMessage, sizeof(customMessage));
messageScrollSpeed = localSpeed;
Serial.printf("[HA] Temporary HA message received: '%s' (persistent: '%s', duration: %ds, scrolls: %d, speed: %d)\n",
customMessage,
strlen(lastPersistentMessage) ? lastPersistentMessage : "(none)",
messageDisplaySeconds, // Added seconds
messageScrollTimes, // Added scrolltimes
localSpeed); // Added speed
} else {
// --- UI-originated message: permanent ---
filtered.toCharArray(customMessage, sizeof(customMessage));
strlcpy(lastPersistentMessage, customMessage, sizeof(lastPersistentMessage));
messageScrollSpeed = GENERAL_SCROLL_SPEED; // Always global for UI
Serial.printf("[UI] Persistent message stored: %s (speed=%d)\n",
customMessage, messageScrollSpeed);
// --- Persist to config.json immediately ---
saveCustomMessageToConfig(customMessage);
}
// --- Activate display ---
displayMode = 6;
prevDisplayMode = 0;
messageStartTime = millis(); // Start the timer
currentScrollCount = 0;
String response = String(isFromHA ? "OK (HA message, speed=" : "OK (UI message, speed=") + String(localSpeed);
response += String(", duration=") + String(messageDisplaySeconds) + "s, scrolls=" + String(messageScrollTimes) + ")";
request->send(200, "text/plain", response);
} else {
Serial.println(F("[MESSAGE] Error: missing 'message' parameter in request."));
request->send(400, "text/plain", "Missing message parameter");
}
});
server.on("/scan", HTTP_GET, [](AsyncWebServerRequest *request) {
int scanStatus = WiFi.scanComplete();
// -2 means scan not triggered, -1 means scan in progress
if (scanStatus < -1 || scanStatus == WIFI_SCAN_FAILED) {
// Start the asynchronous scan
WiFi.scanNetworks(true);
request->send(202, "application/json", "{\"status\":\"processing\"}");
} else if (scanStatus == -1) {
// Scan is currently running
request->send(202, "application/json", "{\"status\":\"processing\"}");
} else {
// Scan finished (scanStatus >= 0)
String json = "[";
for (int i = 0; i < scanStatus; ++i) {
json += "{";
json += "\"ssid\":\"" + WiFi.SSID(i) + "\",";
json += "\"rssi\":" + String(WiFi.RSSI(i));
json += "}";
if (i < scanStatus - 1) json += ",";
}
json += "]";
// Clean up scan results from memory
WiFi.scanDelete();
request->send(200, "application/json", json);
}
});
server.on("/ip", HTTP_GET, [](AsyncWebServerRequest *request) {
String ip;
if (WiFi.getMode() == WIFI_AP) {
ip = WiFi.softAPIP().toString(); // usually 192.168.4.1
} else if (WiFi.isConnected()) {
ip = WiFi.localIP().toString();
} else {
ip = "";
}
request->send(200, "text/plain", ip);
});
server.on("/hostname", HTTP_GET, [](AsyncWebServerRequest *request) {
if (WiFi.getMode() == WIFI_AP) {
request->send(200, "text/plain", "AP-Mode");
} else {
String host = deviceHostname + ".local";
request->send(200, "text/plain", host);
}
});
server.on("/uptime", HTTP_GET, [](AsyncWebServerRequest *request) {
unsigned long seconds = 0;
String formatted = "No uptime recorded yet.";
if (LittleFS.exists("/uptime.dat")) {
File f = LittleFS.open("/uptime.dat", "r");
if (f) {
String content = f.readString();
seconds = content.toInt();
formatted = formatUptime(seconds);
f.close();
}
}
String json = "{";
json += "\"uptime_seconds\":" + String(seconds) + ",";
json += "\"uptime_formatted\":\"" + formatted + "\",";
json += "\"version\":\"" FIRMWARE_VERSION "\"";
json += "}";
request->send(200, "application/json", json);
});
server.on("/export", HTTP_GET, [](AsyncWebServerRequest *request) {
Serial.println(F("[WEBSERVER] Request: /export"));
File f;
if (LittleFS.exists("/config.json")) {
f = LittleFS.open("/config.json", "r");
Serial.println(F("[EXPORT] Using /config.json"));
} else if (LittleFS.exists("/config.bak")) {
f = LittleFS.open("/config.bak", "r");
Serial.println(F("[EXPORT] /config.json not found, using /config.bak"));
} else {
request->send(404, "application/json", "{\"error\":\"No config found\"}");
return;
}
DynamicJsonDocument doc(2048);
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) {
Serial.print(F("[EXPORT] Error parsing config: "));
Serial.println(err.f_str());
request->send(500, "application/json", "{\"error\":\"Failed to parse config\"}");
return;
}
// Only sanitize if NOT in AP mode
if (!isAPMode) {
doc["ssid"] = "********";
doc["password"] = "********";
doc["openWeatherApiKey"] = "********************************";
}
doc["mode"] = isAPMode ? "ap" : "sta";
String jsonOut;
serializeJsonPretty(doc, jsonOut);
AsyncWebServerResponse *resp = request->beginResponse(200, "application/json", jsonOut);
resp->addHeader("Content-Disposition", "attachment; filename=\"config.json\"");
request->send(resp);
});
server.on("/upload", HTTP_GET, [](AsyncWebServerRequest *request) {
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html{
background: linear-gradient(135deg, #081f56 0%, #110f2e 50%, #441a65 100%);
height: 100%;
}
body {
border: solid 1px rgba(255, 255, 255, 0.12);
transition: opacity 0.6s cubic-bezier(.4, 0, .2, 1);
max-width: 300px;
margin: 4rem auto;
background: rgba(255, 255, 255, 0.04);
border-radius: 24px;
text-align: center;
font-family: Roboto, system-ui;
/* margin: 0; */
padding: 2rem 1rem;
color: #ffffff;
background-repeat: no-repeat, repeat, repeat;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-shadow: 0 10px 36px 0 rgba(40, 170, 255, 0.11), 0 2px 8px 0 rgba(44, 70, 110, 0.08);
}
h3 {
margin-top: 0;
}
input::file-selector-button {
background: #0ea5e9;
color: white;
padding: 0.9rem 1.8rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 999px;
cursor: pointer;
text-align: center;
transition: background 0.25s, transform 0.15s
ease-in-out;
margin-right: 0.5rem;
}
</style>
</head>
<body>
<h3>Upload config.json</h3>
<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="file" accept=".json" id="fileInput" onchange="this.form.submit()">
</form>
</body>
</html>
)rawliteral";
request->send(200, "text/html", html);
});
server.on(
"/upload", HTTP_POST, [](AsyncWebServerRequest *request) {
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Upload Successful</title>
<meta http-equiv="refresh" content="1; url=/" />
<style>
html{
background: linear-gradient(135deg, #081f56 0%, #110f2e 50%, #441a65 100%);
height: 100%;
}
body {
border: solid 1px rgba(255, 255, 255, 0.12);
transition: opacity 0.6s cubic-bezier(.4, 0, .2, 1);
max-width: 300px;
margin: 4rem auto;
background: rgba(255, 255, 255, 0.04);
border-radius: 24px;
text-align: center;
font-family: Roboto, system-ui;
/* margin: 0; */
padding: 2rem 1rem;
color: #ffffff;
background-repeat: no-repeat, repeat, repeat;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-shadow: 0 10px 36px 0 rgba(40, 170, 255, 0.11), 0 2px 8px 0 rgba(44, 70, 110, 0.08);
}
h3 {
margin-top: 0;
}
input::file-selector-button {
background: #0ea5e9;
color: white;
padding: 0.9rem 1.8rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 999px;
cursor: pointer;
text-align: center;
transition: background 0.25s, transform 0.15s
ease-in-out;
margin-right: 0.5rem;
}
</style>
</head>
<body>
<h3>File uploaded successfully!</h3>
<p>Returning to main page...</p>
</body>
</html>
)rawliteral";
request->send(200, "text/html", html);
// Restart after short delay to let browser handle redirect
request->onDisconnect([]() {
delay(500); // ensure response is sent
ESP.restart();
});
},
[](AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len, bool final) {
static File f;
if (index == 0) {
f = LittleFS.open("/config.json", "w"); // start new file
}
if (f) f.write(data, len); // write chunk
if (final) f.close(); // finish file
});
server.on("/factory_reset", HTTP_GET, [](AsyncWebServerRequest *request) {
// If not in AP mode, block and return a 403 response
if (!isAPMode) {
request->send(403, "text/plain", "Factory reset only allowed in AP mode.");
Serial.println(F("[RESET] Factory reset attempt blocked (not in AP mode)."));
return;
}
const char *FACTORY_RESET_HTML = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Resetting Device</title>
<style>
html{
background: linear-gradient(135deg, #081f56 0%, #110f2e 50%, #441a65 100%);
height: 100%;
}
body {
border: solid 1px rgba(255, 255, 255, 0.12);
transition: opacity 0.6s cubic-bezier(.4, 0, .2, 1);
max-width: 300px;
margin: 4rem auto;
background: rgba(255, 255, 255, 0.04);
border-radius: 24px;
text-align: center;
font-family: Roboto, system-ui;
/* margin: 0; */
padding: 2rem 1rem;
color: #ffffff;
background-repeat: no-repeat, repeat, repeat;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-shadow: 0 10px 36px 0 rgba(40, 170, 255, 0.11), 0 2px 8px 0 rgba(44, 70, 110, 0.08);
}
h3 { margin-top: 0; color: #ff9999; }
p { font-size: 1.1em; }
.warning { font-size: 1.2em; font-weight: bold; color: #fff; margin-top: 15px; }
</style>
</head>
<body>
<h3>Factory Reset Initiated</h3>
<p>All saved configuration and Wi-Fi credentials are now being erased.</p>
<hr style="margin: 15px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.2);">
<p class="warning"><span style="color: yellow;">⚠️</span> ACTION REQUIRED</p>
<p>
The device is rebooting and will be temporarily offline for about <strong>45 seconds</strong>.
<br><br>
<strong>Your browser will disconnect automatically.</strong>
</p>
<p>
<strong>Next steps:</strong>
<br>1. Wait about 45 seconds for the reboot to finish.<br>
2. Reconnect your PC or phone to the Wi-Fi network: <strong>ESPTimeCast</strong>.<br>
3. Open your browser and go to <strong>192.168.4.1</strong> to continue setup.
</p>
</body>
</html>
)rawliteral";
request->send(200, "text/html", FACTORY_RESET_HTML);
Serial.println(F("[RESET] Factory reset requested, initiating cleanup..."));
// Use onDisconnect() to ensure the HTTP response is fully sent before the disruptive actions
request->onDisconnect([]() {
// Small delay to ensure the response buffer is flushed before file ops
delay(500);
// --- Remove configuration and uptime files ---
const char *filesToRemove[] = { "/config.json", "/uptime.dat", "/index.html" };
for (auto &file : filesToRemove) {
if (LittleFS.exists(file)) {
if (LittleFS.remove(file)) {
Serial.printf("[RESET] Deleted %s\n", file);
} else {
Serial.printf("[RESET] ERROR deleting %s\n", file);
}
} else {
Serial.printf("[RESET] %s not found, skipping delete.\n", file);
}
}
// --- Clear Wi-Fi credentials ---
#if defined(ESP8266)
WiFi.disconnect(true); // true = wipe credentials
#elif defined(ESP32)
WiFi.disconnect(true, true); // (erase=true, wifioff=true)
#endif
Serial.println(F("[RESET] Factory defaults restored. Rebooting..."));
delay(500);
ESP.restart();
});
});
server.onNotFound(handleCaptivePortal);
server.begin();
Serial.println(F("[WEBSERVER] Web server started"));
}
void handleCaptivePortal(AsyncWebServerRequest *request) {
String uri = request->url();
// Never interfere with real UI or API
if (
uri == "/" || uri == "/index.html" || uri.startsWith("/config") || uri.startsWith("/hostname") || uri.startsWith("/ip") || uri.endsWith(".json") || uri.endsWith(".js") || uri.endsWith(".css") || uri.endsWith(".png") || uri.endsWith(".ico")) {
return; // let normal handlers serve it
}
// Known captive portal probes → redirect
if (
uri == "/generate_204" || uri == "/gen_204" || uri == "/fwlink" || uri == "/hotspot-detect.html" || uri == "/ncsi.txt" || uri == "/cp/success.txt" || uri == "/library/test/success.html") {
if (isAPMode) {
IPAddress apIP = WiFi.softAPIP();
String redirectUrl = "http://" + apIP.toString() + "/";
//Serial.printf("[WEBSERVER] Captive probe %s → redirect\n", uri.c_str());
request->redirect(redirectUrl);
return;
}
}
// Unknown URLs in AP mode → redirect (helps odd OSes like /chat)
if (isAPMode) {
IPAddress apIP = WiFi.softAPIP();
String redirectUrl = "http://" + apIP.toString() + "/";
Serial.printf("[WEBSERVER] Captive fallback redirect: %s\n", uri.c_str());
request->redirect(redirectUrl);
return;
}
// STA mode fallback
request->send(404, "text/plain", "Not found");
}
String normalizeWeatherDescription(String str) {
// Serbian Cyrillic → Latin
str.replace("а", "a");
str.replace("б", "b");
str.replace("в", "v");
str.replace("г", "g");
str.replace("д", "d");
str.replace("ђ", "dj");
str.replace("е", "e");
str.replace("ё", "e"); // Russian
str.replace("ж", "z");
str.replace("з", "z");
str.replace("и", "i");
str.replace("й", "j"); // Russian
str.replace("ј", "j"); // Serbian
str.replace("к", "k");
str.replace("л", "l");
str.replace("љ", "lj");
str.replace("м", "m");
str.replace("н", "n");
str.replace("њ", "nj");
str.replace("о", "o");
str.replace("п", "p");
str.replace("р", "r");
str.replace("с", "s");
str.replace("т", "t");
str.replace("ћ", "c");
str.replace("у", "u");
str.replace("ф", "f");
str.replace("х", "h");
str.replace("ц", "c");
str.replace("ч", "c");
str.replace("џ", "dz");
str.replace("ш", "s");
str.replace("щ", "sh"); // Russian
str.replace("ы", "y"); // Russian
str.replace("э", "e"); // Russian
str.replace("ю", "yu"); // Russian
str.replace("я", "ya"); // Russian
// Latin diacritics → ASCII
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.toUpperCase();
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() {
#if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32S2)
String base = "http://api.openweathermap.org/data/2.5/weather?";
#else
String base = "https://api.openweathermap.org/data/2.5/weather?";
#endif
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;
// Create encoded copies
String cityEncoded = String(openWeatherCity);
String countryEncoded = String(openWeatherCountry);
cityEncoded.replace(" ", "%20");
countryEncoded.replace(" ", "%20");
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=" + cityEncoded + "," + countryEncoded;
}
base += "&appid=" + String(openWeatherApiKey);
base += "&units=" + String(weatherUnits);
String langForAPI = String(language);
if (langForAPI == "eo" || langForAPI == "ga" || langForAPI == "sw" || langForAPI == "ja") {
langForAPI = "en";
}
base += "&lang=" + langForAPI;
return base;
}
void fetchWeather() {
if (millis() - lastWifiConnectTime < 5000) {
Serial.println(F("[WEATHER] Skipped: Network just reconnected. Letting it stabilize..."));
return; // Stop execution if connection is less than 5 seconds old
}
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
#if defined(ESP8266) || defined(CONFIG_IDF_TARGET_ESP32S2)
// ===== ESP8266 → HTTP =====
WiFiClient client;
client.stop();
yield();
http.begin(client, url);
#else
// ===== ESP32 → HTTPS =====
WiFiClientSecure client;
client.stop();
client.setInsecure(); // no cert validation
yield();
http.begin(client, url);
#endif
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());
// -----------------------------------------
// Sunrise/Sunset for Auto Dimming (local time)
// -----------------------------------------
if (doc.containsKey(F("sys"))) {
JsonObject sys = doc[F("sys")];
if (sys.containsKey(F("sunrise")) && sys.containsKey(F("sunset"))) {
// OWM gives UTC timestamps
time_t sunriseUtc = sys[F("sunrise")].as<time_t>();
time_t sunsetUtc = sys[F("sunset")].as<time_t>();
// Get local timezone offset (in seconds)
long tzOffset = 0;
struct tm local_tm;
time_t now = time(nullptr);
if (localtime_r(&now, &local_tm)) {
tzOffset = mktime(&local_tm) - now;
}
// Convert UTC → local
time_t sunriseLocal = sunriseUtc + tzOffset;
time_t sunsetLocal = sunsetUtc + tzOffset;
// Break into hour/minute
struct tm tmSunrise, tmSunset;
localtime_r(&sunriseLocal, &tmSunrise);
localtime_r(&sunsetLocal, &tmSunset);
sunriseHour = tmSunrise.tm_hour;
sunriseMinute = tmSunrise.tm_min;
sunsetHour = tmSunset.tm_hour;
sunsetMinute = tmSunset.tm_min;
Serial.printf("[WEATHER] Adjusted Sunrise/Sunset (local): %02d:%02d | %02d:%02d\n",
sunriseHour, sunriseMinute, sunsetHour, sunsetMinute);
} else {
Serial.println(F("[WEATHER] Sunrise/Sunset not found in JSON."));
}
} else {
Serial.println(F("[WEATHER] 'sys' object not found in JSON payload."));
}
weatherFetched = true;
// -----------------------------------------
// Save updated sunrise/sunset to config.json
// -----------------------------------------
if (autoDimmingEnabled && sunriseHour >= 0 && sunsetHour >= 0) {
File configFile = LittleFS.open("/config.json", "r");
DynamicJsonDocument doc(1024);
if (configFile) {
DeserializationError error = deserializeJson(doc, configFile);
configFile.close();
if (!error) {
// Check if ANY value has changed
bool valuesChanged =
(doc["sunriseHour"].as<int>() != sunriseHour || doc["sunriseMinute"].as<int>() != sunriseMinute || doc["sunsetHour"].as<int>() != sunsetHour || doc["sunsetMinute"].as<int>() != sunsetMinute);
if (valuesChanged) { // Only write if a change occurred
doc["sunriseHour"] = sunriseHour;
doc["sunriseMinute"] = sunriseMinute;
doc["sunsetHour"] = sunsetHour;
doc["sunsetMinute"] = sunsetMinute;
File f = LittleFS.open("/config.json", "w");
if (f) {
serializeJsonPretty(doc, f);
f.close();
Serial.println(F("[WEATHER] SAVED NEW sunrise/sunset to config.json (Values changed)"));
} else {
Serial.println(F("[WEATHER] Failed to write updated sunrise/sunset to config.json"));
}
} else {
Serial.println(F("[WEATHER] Sunrise/Sunset unchanged, skipping config save."));
}
// --- END MODIFIED COMPARISON LOGIC ---
} else {
Serial.println(F("[WEATHER] JSON parse error when saving updated sunrise/sunset"));
}
}
}
} 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();
}
// -----------------------------
// Load uptime from LittleFS
// -----------------------------
void loadUptime() {
if (LittleFS.exists("/uptime.dat")) {
File f = LittleFS.open("/uptime.dat", "r");
if (f) {
totalUptimeSeconds = f.parseInt();
f.close();
bootMillis = millis();
Serial.printf("[UPTIME] Loaded accumulated uptime: %lu seconds (%.2f hours)\n",
totalUptimeSeconds, totalUptimeSeconds / 3600.0);
} else {
Serial.println(F("[UPTIME] Failed to open /uptime.dat for reading."));
totalUptimeSeconds = 0;
bootMillis = millis();
}
} else {
Serial.println(F("[UPTIME] No previous uptime file found. Starting from 0."));
totalUptimeSeconds = 0;
bootMillis = millis();
}
}
// -----------------------------
// Save uptime to LittleFS
// -----------------------------
void saveUptime() {
// Use getTotalRuntimeSeconds() to include current session
totalUptimeSeconds = getTotalRuntimeSeconds();
bootMillis = millis(); // reset session start
File f = LittleFS.open("/uptime.dat", "w");
if (f) {
f.print(totalUptimeSeconds);
f.close();
Serial.printf("[UPTIME] Saved accumulated uptime: %s\n", formatTotalRuntime().c_str());
} else {
Serial.println(F("[UPTIME] Failed to write /uptime.dat"));
}
}
// -----------------------------
// Get total uptime including current session
// -----------------------------
unsigned long getTotalRuntimeSeconds() {
return totalUptimeSeconds + (millis() - bootMillis) / 1000;
}
// -----------------------------
// Format total uptime as HH:MM:SS
// -----------------------------
String formatTotalRuntime() {
unsigned long secs = getTotalRuntimeSeconds();
unsigned int h = secs / 3600;
unsigned int m = (secs % 3600) / 60;
unsigned int s = secs % 60;
char buf[16];
sprintf(buf, "%02u:%02u:%02u", h, m, s);
return String(buf);
}
void saveCustomMessageToConfig(const char *msg) {
Serial.println(F("[CONFIG] Updating customMessage in config.json..."));
DynamicJsonDocument doc(2048);
// Load existing config.json (if present)
File configFile = LittleFS.open("/config.json", "r");
if (configFile) {
DeserializationError err = deserializeJson(doc, configFile);
configFile.close();
if (err) {
Serial.print(F("[CONFIG] Error reading existing config: "));
Serial.println(err.f_str());
}
}
// Update only customMessage
doc["customMessage"] = msg;
// Safely write back to config.json
if (LittleFS.exists("/config.json")) {
LittleFS.rename("/config.json", "/config.bak");
}
File f = LittleFS.open("/config.json", "w");
if (!f) {
Serial.println(F("[CONFIG] ERROR: Failed to open /config.json for writing"));
return;
}
size_t bytesWritten = serializeJson(doc, f);
f.close();
Serial.printf("[CONFIG] Saved customMessage='%s' (%u bytes written)\n", msg, bytesWritten);
}
// Returns formatted uptime (for web UI or logs)
String formatUptime(unsigned long seconds) {
unsigned long days = seconds / 86400;
unsigned long hours = (seconds % 86400) / 3600;
unsigned long minutes = (seconds % 3600) / 60;
unsigned long secs = seconds % 60;
char buf[64];
if (days > 0)
sprintf(buf, "%lud %02lu:%02lu:%02lu", days, hours, minutes, secs);
else
sprintf(buf, "%02lu:%02lu:%02lu", hours, minutes, secs);
return String(buf);
}
// -----------------------------------------------------------------------------
// Main setup() and loop()
// -----------------------------------------------------------------------------
/*
DisplayMode key:
0: Clock
1: Weather
2: Weather Description
3: Countdown
4: Nightscout
5: Date
6: Custom Message
*/
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."));
loadUptime();
ensureHtmlFileExists();
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"));
#if defined(ESP32)
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.persistent(false);
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info) {
const char *name = nullptr;
switch (event) {
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
name = "GOT_IP";
lastWifiConnectTime = millis();
Serial.println("[WIFI EVENT] Re-initializing mDNS due to new IP.");
setupMDNS();
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
name = "DISCONNECTED";
MDNS.end();
Serial.println("[WIFI EVENT] mDNS stopped.");
break;
default: return; // ignore all other events
}
Serial.printf("[WIFI EVENT] %s (%d)\n", name, event);
});
#elif defined(ESP8266)
WiFi.setAutoReconnect(true);
WiFi.persistent(false);
mConnectHandler = WiFi.onStationModeConnected([](const WiFiEventStationModeConnected &ev) {
Serial.println("[WIFI EVENT] Connected");
});
mDisConnectHandler = WiFi.onStationModeDisconnected([](const WiFiEventStationModeDisconnected &ev) {
Serial.printf("[WIFI EVENT] Disconnected (Reason: %d)\n", ev.reason);
MDNS.end();
Serial.println("[WIFI EVENT] mDNS stopped.");
});
mGotIpHandler = WiFi.onStationModeGotIP([](const WiFiEventStationModeGotIP &ev) {
Serial.printf("[WIFI EVENT] GOT_IP - IP: %s\n", ev.ip.toString().c_str());
lastWifiConnectTime = millis();
Serial.println("[WIFI EVENT] Re-initializing mDNS due to new IP.");
setupMDNS();
});
#endif
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."));
}
if (!isAPMode && WiFi.status() == WL_CONNECTED) {
setupMDNS();
}
setupWebServer();
Serial.println(F("[SETUP] Webserver setup complete"));
Serial.println(F("[SETUP] Setup complete"));
Serial.println();
printConfigToSerial();
setupTime();
displayMode = 0;
lastSwitch = millis() - (clockDuration - 500);
lastColonBlink = millis();
bootMillis = millis();
saveUptime();
}
void ensureHtmlFileExists() {
Serial.println(F("[FS] Checking for /index.html on LittleFS..."));
// Length of embedded HTML in PROGMEM
size_t expectedSize = strlen_P(index_html);
// If the file exists, verify size before deciding to trust it
if (LittleFS.exists("/index.html")) {
File f = LittleFS.open("/index.html", "r");
if (!f) {
Serial.println(F("[FS] ERROR: /index.html exists but failed to open! Will rewrite."));
} else {
bool identical = true;
for (size_t i = 0; i < expectedSize; i++) {
if (!f.available()) {
identical = false;
break;
}
char fileChar = f.read();
char progChar = pgm_read_byte_near(index_html + i);
if (fileChar != progChar) {
identical = false;
break;
}
}
// Also check if file has extra trailing bytes
if (f.available()) {
identical = false;
}
f.close();
if (identical) {
Serial.printf("[FS] /index.html content identical (%u bytes). Using file system version.\n", expectedSize);
return;
}
Serial.println(F("[FS] /index.html content differs. Rewriting..."));
}
} else {
Serial.println(F("[FS] /index.html NOT found. Writing embedded content to LittleFS..."));
}
// -------------------------------
// Write embedded HTML to LittleFS
// -------------------------------
File f = LittleFS.open("/index.html", "w");
if (!f) {
Serial.println(F("[FS] ERROR: Failed to create /index.html for writing!"));
return;
}
size_t htmlLength = expectedSize;
size_t bytesWritten = 0;
for (size_t i = 0; i < htmlLength; i++) {
char c = pgm_read_byte_near(index_html + i);
if (f.write((uint8_t *)&c, 1) == 1) {
bytesWritten++;
} else {
Serial.printf("[FS] Write failure at character %u. Aborting write.\n", i);
f.close();
return;
}
}
f.close();
if (bytesWritten == htmlLength) {
Serial.printf("[FS] Successfully wrote %u bytes to /index.html.\n", bytesWritten);
} else {
Serial.printf("[FS] WARNING: Only wrote %u of %u bytes to /index.html (might be incomplete).\n",
bytesWritten, htmlLength);
}
}
void advanceDisplayMode() {
// If user requested clock-only during dimming and we are currently dimmed, stay on clock
if (clockOnlyDuringDimming) {
time_t now = time(nullptr);
struct tm local_tm;
localtime_r(&now, &local_tm);
int curTotal = local_tm.tm_hour * 60 + local_tm.tm_min;
int startTotal = -1, endTotal = -1;
bool currentlyDimmed = false;
if (autoDimmingEnabled) {
startTotal = sunsetHour * 60 + sunsetMinute;
endTotal = sunriseHour * 60 + sunriseMinute;
currentlyDimmed = (startTotal < endTotal)
? (curTotal >= startTotal && curTotal < endTotal)
: (curTotal >= startTotal || curTotal < endTotal);
} else if (dimmingEnabled) {
startTotal = dimStartHour * 60 + dimStartMinute;
endTotal = dimEndHour * 60 + dimEndMinute;
currentlyDimmed = (startTotal < endTotal)
? (curTotal >= startTotal && curTotal < endTotal)
: (curTotal >= startTotal || curTotal < endTotal);
}
if (currentlyDimmed) {
displayMode = 0;
lastSwitch = millis();
Serial.println(F("[DISPLAY] advanceDisplayMode(): Staying in CLOCK because Clock-only-dimming is enabled and dimming is active."));
return;
}
}
prevDisplayMode = displayMode;
int oldMode = displayMode;
String ntpField = String(ntpServer2);
bool nightscoutConfigured = ntpField.startsWith("https://");
if (displayMode == 0) { // Clock -> ...
if (showDate) {
displayMode = 5; // Date mode right after Clock
Serial.println(F("[DISPLAY] Switching to display mode: DATE (from Clock)"));
} else if (weatherAvailable && (strlen(openWeatherApiKey) == 32) && (strlen(openWeatherCity) > 0) && (strlen(openWeatherCountry) > 0)) {
displayMode = 1;
Serial.println(F("[DISPLAY] Switching to display mode: WEATHER (from Clock)"));
} else if (countdownEnabled && !countdownFinished && ntpSyncSuccessful && countdownTargetTimestamp > 0 && countdownTargetTimestamp > time(nullptr)) {
displayMode = 3;
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Clock, weather skipped)"));
} else if (nightscoutConfigured) {
displayMode = 4; // Clock -> Nightscout (if weather & countdown are skipped)
Serial.println(F("[DISPLAY] Switching to display mode: NIGHTSCOUT (from Clock, weather & countdown skipped)"));
} else {
displayMode = 0;
Serial.println(F("[DISPLAY] Staying in CLOCK (from Clock)"));
}
} else if (displayMode == 5) { // Date mode
if (weatherAvailable && (strlen(openWeatherApiKey) == 32) && (strlen(openWeatherCity) > 0) && (strlen(openWeatherCountry) > 0)) {
displayMode = 1;
Serial.println(F("[DISPLAY] Switching to display mode: WEATHER (from Date)"));
} else if (countdownEnabled && !countdownFinished && ntpSyncSuccessful && countdownTargetTimestamp > 0 && countdownTargetTimestamp > time(nullptr)) {
displayMode = 3;
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Date, weather skipped)"));
} else if (nightscoutConfigured) {
displayMode = 4;
Serial.println(F("[DISPLAY] Switching to display mode: NIGHTSCOUT (from Date, weather & countdown skipped)"));
} else {
displayMode = 0;
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Date)"));
}
} else if (displayMode == 1) { // Weather -> ...
if (showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) {
displayMode = 2;
Serial.println(F("[DISPLAY] Switching to display mode: DESCRIPTION (from Weather)"));
} else if (countdownEnabled && !countdownFinished && ntpSyncSuccessful && countdownTargetTimestamp > 0 && countdownTargetTimestamp > time(nullptr)) {
displayMode = 3;
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Weather)"));
} else if (nightscoutConfigured) {
displayMode = 4; // Weather -> Nightscout (if description & countdown are skipped)
Serial.println(F("[DISPLAY] Switching to display mode: NIGHTSCOUT (from Weather, description & countdown skipped)"));
} else {
displayMode = 0;
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Weather)"));
}
} else if (displayMode == 2) { // Weather Description -> ...
if (countdownEnabled && !countdownFinished && ntpSyncSuccessful && countdownTargetTimestamp > 0 && countdownTargetTimestamp > time(nullptr)) {
displayMode = 3;
Serial.println(F("[DISPLAY] Switching to display mode: COUNTDOWN (from Description)"));
} else if (nightscoutConfigured) {
displayMode = 4; // Description -> Nightscout (if countdown is skipped)
Serial.println(F("[DISPLAY] Switching to display mode: NIGHTSCOUT (from Description, countdown skipped)"));
} else {
displayMode = 0;
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Description)"));
}
} else if (displayMode == 3) { // Countdown -> Nightscout
if (nightscoutConfigured) {
displayMode = 4;
Serial.println(F("[DISPLAY] Switching to display mode: NIGHTSCOUT (from Countdown)"));
} else {
displayMode = 0;
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Countdown)"));
}
} else if (displayMode == 4) { // Nightscout -> Custom Message
displayMode = 6;
Serial.println(F("[DISPLAY] Switching to display mode: CUSTOM MESSAGE (from Nightscout)"));
} else if (displayMode == 6) { // Custom Message -> Clock
displayMode = 0;
Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Custom Message)"));
}
// --- Common cleanup/reset logic remains the same ---
if ((displayMode == 0) && strlen(customMessage) > 0 && oldMode != 6) {
displayMode = 6;
Serial.println(F("[DISPLAY] Custom Message display before returning to CLOCK"));
}
lastSwitch = millis();
}
void advanceDisplayModeSafe() {
int attempts = 0;
const int MAX_ATTEMPTS = 7; // Number of possible modes + 1
int startMode = displayMode;
bool valid = false;
do {
advanceDisplayMode(); // One step advance
attempts++;
// Recalculate validity for the new mode
valid = false;
String ntpField = String(ntpServer2);
bool nightscoutConfigured = ntpField.startsWith("https://");
if (displayMode == 0) valid = true; // Clock always valid
else if (displayMode == 5 && showDate) valid = true;
else if (displayMode == 1 && weatherAvailable && (strlen(openWeatherApiKey) == 32) && (strlen(openWeatherCity) > 0) && (strlen(openWeatherCountry) > 0)) valid = true;
else if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) valid = true;
else if (displayMode == 3 && countdownEnabled && !countdownFinished && ntpSyncSuccessful) valid = true;
else if (displayMode == 4 && nightscoutConfigured) valid = true;
else if (displayMode == 6 && strlen(customMessage) > 0) valid = true;
// If we've looped back to where we started, break to avoid infinite loop
if (displayMode == startMode) break;
if (valid) break;
} while (attempts < MAX_ATTEMPTS);
// If no valid mode found, fall back to Clock
if (!valid) {
displayMode = 0;
Serial.println(F("[DISPLAY] Safe fallback to CLOCK"));
}
lastSwitch = millis();
}
//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;
countdownObj["isDramaticCountdown"] = isDramaticCountdown;
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();
// AP Mode animation
static unsigned long apAnimTimer = 0;
static int apAnimFrame = 0;
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;
}
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
// mDNS update 8266 only
MDNS.update();
// -----------------------------
// Dimming (auto + manual)
// -----------------------------
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;
// -----------------------------
// Determine dimming start/end
// -----------------------------
int startTotal, endTotal;
bool dimActive = false;
if (autoDimmingEnabled) {
startTotal = sunsetHour * 60 + sunsetMinute;
endTotal = sunriseHour * 60 + sunriseMinute;
} else if (dimmingEnabled) {
startTotal = dimStartHour * 60 + dimStartMinute;
endTotal = dimEndHour * 60 + dimEndMinute;
} else {
startTotal = endTotal = -1; // not used
}
// -----------------------------
// Check if dimming should be active
// -----------------------------
if (autoDimmingEnabled || dimmingEnabled) {
if (startTotal < endTotal) {
dimActive = (curTotal >= startTotal && curTotal < endTotal);
} else {
dimActive = (curTotal >= startTotal || curTotal < endTotal); // overnight
}
}
// -----------------------------
// Apply brightness / display on-off
// -----------------------------
static bool lastDimActive = false; // remembers last state
int targetBrightness = dimActive ? dimBrightness : brightness;
// Log only when transitioning
if (dimActive != lastDimActive) {
if (dimActive) {
if (autoDimmingEnabled)
Serial.printf("[DISPLAY] Automatic dimming setting brightness to %d\n", targetBrightness);
else if (dimmingEnabled)
Serial.printf("[DISPLAY] Custom dimming setting brightness to %d\n", targetBrightness);
} else {
Serial.println(F("[DISPLAY] Waking display (dimming end)"));
}
lastDimActive = dimActive;
}
// Apply brightness or shutdown
if (targetBrightness == -1) {
if (!displayOff) {
Serial.println(F("[DISPLAY] Turning display OFF (dimming -1)"));
P.displayShutdown(true);
P.displayClear();
displayOff = true;
displayOffByDimming = dimActive;
displayOffByBrightness = !dimActive;
}
} else {
if (displayOff && ((dimActive && displayOffByBrightness) || (!dimActive && displayOffByDimming))) {
P.displayShutdown(false);
displayOff = false;
displayOffByDimming = false;
displayOffByBrightness = false;
}
P.setIntensity(targetBrightness);
}
// Enforce "Clock only during dimming" if enabled
if (clockOnlyDuringDimming && dimActive) {
if (displayMode != 0) {
prevDisplayMode = displayMode;
displayMode = 0;
lastSwitch = millis();
Serial.println(F("[DISPLAY] Forcing CLOCK because 'Clock only during dimming' is enabled and dimming is active."));
}
}
// --- 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
}
// --- BRIGHTNESS/OFF CHECK ---
if (brightness == -1) {
if (!displayOff) {
Serial.println(F("[DISPLAY] Turning display OFF"));
P.displayShutdown(true); // fully off
P.displayClear();
displayOff = true;
}
yield();
}
// --- 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;
static unsigned long lastNtpRetryAttempt = 0;
static bool firstRetry = true;
if (lastNtpRetryAttempt == 0) {
lastNtpRetryAttempt = millis(); // set baseline on first fail
}
unsigned long ntpRetryInterval = firstRetry ? 30000UL : 300000UL; // first retry after 30s, after that every 5 minutes
if (millis() - lastNtpRetryAttempt > ntpRetryInterval) {
lastNtpRetryAttempt = millis();
ntpRetryCount = 0;
ntpStartTime = millis();
ntpState = NTP_SYNCING;
Serial.println(F("[TIME] Retrying NTP sync..."));
firstRetry = false;
}
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];
// build base HH:MM first ---
char baseTime[9];
if (twelveHourToggle) {
int hour12 = timeinfo.tm_hour % 12;
if (hour12 == 0) hour12 = 12;
sprintf(baseTime, "%d:%02d", hour12, timeinfo.tm_min);
} else {
sprintf(baseTime, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
}
// add seconds only if colon blink enabled AND weekday hidden ---
char timeWithSeconds[12];
if (!showDayOfWeek && colonBlinkEnabled) {
// Remove any leading space from baseTime
const char *trimmedBase = baseTime;
if (baseTime[0] == ' ') trimmedBase++; // skip leading space
sprintf(timeWithSeconds, "%s:%02d", trimmedBase, timeinfo.tm_sec);
} else {
strcpy(timeWithSeconds, baseTime); // no seconds
}
// keep spacing logic the same ---
char timeSpacedStr[24];
int j = 0;
for (int i = 0; timeWithSeconds[i] != '\0'; i++) {
timeSpacedStr[j++] = timeWithSeconds[i];
if (timeWithSeconds[i + 1] != '\0') {
timeSpacedStr[j++] = ' ';
}
}
timeSpacedStr[j] = '\0';
// build final string ---
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);
// --- NTP SYNC ---
if (ntpState == NTP_SYNCING) {
if (ntpSyncSuccessful || ntpRetryCount >= maxNtpRetries || millis() - ntpStartTime > ntpTimeout) {
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++;
}
}
// --- NTP / WEATHER ERROR ---
else if (!ntpSyncSuccessful) {
P.setTextAlignment(PA_CENTER);
static unsigned long errorAltTimer = 0;
static bool showNtpError = true;
if (!ntpSyncSuccessful && !weatherAvailable) {
if (millis() - errorAltTimer > 2000) {
errorAltTimer = millis();
showNtpError = !showNtpError;
}
P.print(showNtpError ? F("(<") : F("(*"));
} else if (!ntpSyncSuccessful) {
P.print(F("(<"));
} else if (!weatherAvailable) {
P.print(F("(*"));
}
}
// --- DISPLAY CLOCK ---
else {
String timeString = formattedTime;
if (showDayOfWeek && colonBlinkEnabled && !colonVisible) {
timeString.replace(":", " ");
}
bool shouldScrollIn = false;
if (prevDisplayMode == -1 || prevDisplayMode == 3 || prevDisplayMode == 4) {
shouldScrollIn = true; // first boot or other special modes
} else if (prevDisplayMode == 2 && weatherDescription.length() > 8) {
shouldScrollIn = true; // only scroll in if weather was scrolling
} else if (prevDisplayMode == 6) {
shouldScrollIn = true; // scroll in when coming from custom message
}
if (shouldScrollIn && !clockScrollDone) {
textEffect_t inDir = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayText(
timeString.c_str(),
PA_CENTER,
GENERAL_SCROLL_SPEED,
0,
inDir,
PA_NO_EFFECT);
while (!P.displayAnimate()) yield();
clockScrollDone = true; // mark scroll done
} else {
P.setTextAlignment(PA_CENTER);
P.print(timeString);
}
}
yield();
} else {
// --- leaving clock mode ---
if (prevDisplayMode == 0) {
clockScrollDone = false; // reset for next time we enter clock
}
}
// --- 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;
// --- Check if humidity is actually visible ---
bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0;
// --- Conditional padding ---
bool addPadding = false;
if (prevDisplayMode == 1 && humidityVisible) {
addPadding = true;
}
if (addPadding) {
desc = " " + desc; // 4-space padding before scrolling
}
// prepare safe buffer
static char descBuffer[128]; // large enough for OWM translations
desc.toCharArray(descBuffer, sizeof(descBuffer));
if (desc.length() > 8) {
if (!descScrolling) {
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayScroll(descBuffer, 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(descBuffer);
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 part of the code remains unchanged.
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 LOGIC ---
// This 'else' block will only run if `timeRemaining > 0` and `!countdownShowFinishedMessage`
else {
// The new variable `isDramaticCountdown` toggles between the two modes
if (isDramaticCountdown) {
// --- EXISTING DRAMATIC COUNTDOWN LOGIC ---
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:
{ // Seconds & Label Scroll
time_t segmentStartTime = time(nullptr);
unsigned long segmentStartMillis = millis();
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);
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);
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);
while (!P.displayAnimate()) {
yield();
}
countdownSegment++;
segmentStartTime = millis();
break;
}
case 4: // Exit countdown
Serial.println("[COUNTDOWN-STATIC] All 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();
}
// --- NEW: SINGLE-LINE COUNTDOWN LOGIC ---
else {
long days = timeRemaining / (24 * 3600);
long hours = (timeRemaining % (24 * 3600)) / 3600;
long minutes = (timeRemaining % 3600) / 60;
long seconds = timeRemaining % 60;
String label;
// Check if countdownLabel is empty and grab a random one if needed
if (strlen(countdownLabel) > 0) {
label = String(countdownLabel);
label.trim();
// Replace standard digits 09 with your custom font character codes
for (int i = 0; i < label.length(); i++) {
if (isDigit(label[i])) {
int num = label[i] - '0'; // 09
label[i] = 145 + ((num + 9) % 10); // Maps 0→154, 1→145, ... 9→153
}
}
} else {
static const char *fallbackLabels[] = {
"PARTY TIME", "SHOWTIME", "CLOCKOUT", "BLASTOFF",
"GO TIME", "LIFTOFF", "THE BIG REVEAL",
"ZERO HOUR", "THE FINAL COUNT", "MISSION COMPLETE"
};
int randomIndex = random(0, 10);
label = fallbackLabels[randomIndex];
}
// Format the full string
char buf[50];
// Only show days if there are any, otherwise start with hours
if (days > 0) {
sprintf(buf, "%s IN: %ldD %02ldH %02ldM %02ldS", label.c_str(), days, hours, minutes, seconds);
} else {
sprintf(buf, "%s IN: %02ldH %02ldM %02ldS", label.c_str(), hours, minutes, seconds);
}
String fullString = String(buf);
bool addPadding = false;
bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0;
// Padding logic
if (prevDisplayMode == 0 && (showDayOfWeek || colonBlinkEnabled)) {
addPadding = true;
} else if (prevDisplayMode == 1 && humidityVisible) {
addPadding = true;
}
if (addPadding) {
fullString = " " + fullString; // 4 spaces
}
// Display the full string and scroll it
P.setTextAlignment(PA_LEFT);
P.setCharSpacing(1);
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
P.displayScroll(fullString.c_str(), PA_LEFT, actualScrollDirection, GENERAL_SCROLL_SPEED);
// Blocking loop to ensure the full message scrolls
while (!P.displayAnimate()) {
yield();
}
// After scrolling is complete, we're done with this display mode
// Move to the next mode and exit the function.
P.setTextAlignment(PA_CENTER);
advanceDisplayMode();
yield();
return;
}
}
// Keep alignment reset just in case
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
yield();
return;
} // End of if (displayMode == 3 && ...)
// // --- NIGHTSCOUT Display Mode ---
if (displayMode == 4) {
String ntpField = String(ntpServer2);
// These static variables will retain their values between calls to this block
static unsigned long lastNightscoutFetchTime = 0;
const unsigned long NIGHTSCOUT_FETCH_INTERVAL = 150000; // 2.5 minutes
static int currentGlucose = -1;
static String currentDirection = "?";
static time_t lastGlucoseTime = 0; // store timestamp from JSON
// --- Small helper inside this block ---
auto makeTimeUTC = [](struct tm *tm) -> time_t {
#if defined(ESP32)
// ESP32: timegm() is not implemented — emulate correctly
struct tm tm_copy = *tm;
// mktime() interprets tm as local, but system time is UTC already
// so we can safely assume input is UTC
return mktime(&tm_copy);
#elif defined(ESP8266)
// ESP8266: timegm() not available either, same logic
struct tm tm_copy = *tm;
return mktime(&tm_copy);
#else
// Platforms with proper timegm()
return timegm(tm);
#endif
};
// --------------------------------------
// Check if it's time to fetch new data or if we have no data yet
if (currentGlucose == -1 || millis() - lastNightscoutFetchTime >= NIGHTSCOUT_FETCH_INTERVAL) {
WiFiClientSecure client;
client.setInsecure();
HTTPClient https;
https.begin(client, ntpField);
client.setBufferSizes(512, 512);
https.setTimeout(5000);
Serial.println("[HTTPS] Nightscout fetch initiated...");
int httpCode = https.GET();
if (httpCode == HTTP_CODE_OK) {
String payload = https.getString();
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error && doc.is<JsonArray>() && doc.size() > 0) {
JsonObject firstReading = doc[0].as<JsonObject>();
currentGlucose = firstReading["glucose"] | firstReading["sgv"] | -1;
currentDirection = firstReading["direction"] | "?";
const char *dateStr = firstReading["dateString"];
// --- Parse ISO 8601 UTC time ---
if (dateStr) {
struct tm tm {};
if (sscanf(dateStr, "%4d-%2d-%2dT%2d:%2d:%2dZ",
&tm.tm_year, &tm.tm_mon, &tm.tm_mday,
&tm.tm_hour, &tm.tm_min, &tm.tm_sec)
== 6) {
tm.tm_year -= 1900;
tm.tm_mon -= 1;
lastGlucoseTime = makeTimeUTC(&tm);
}
}
Serial.printf("Nightscout data fetched: %d mg/dL %s\n", currentGlucose, currentDirection.c_str());
} else {
Serial.println("Failed to parse Nightscout JSON");
}
} else {
Serial.printf("[HTTPS] GET failed, error: %s\n", https.errorToString(httpCode).c_str());
}
https.end();
lastNightscoutFetchTime = millis();
}
// --- Display the data ---
if (currentGlucose != -1) {
// Calculate age of reading
// Get current UTC time (avoid local timezone offset)
time_t nowLocal = time(nullptr);
struct tm *gmt = gmtime(&nowLocal);
time_t nowUTC = mktime(gmt);
bool isOutdated = false;
int ageMinutes = 0;
if (lastGlucoseTime > 0) {
double diffSec = difftime(nowUTC, lastGlucoseTime);
ageMinutes = (int)(diffSec / 60.0);
isOutdated = (ageMinutes > NIGHTSCOUT_IDLE_THRESHOLD_MIN);
Serial.printf("[NIGHTSCOUT] Data age: %d minutes old (threshold: %d)\n", ageMinutes, NIGHTSCOUT_IDLE_THRESHOLD_MIN);
}
// Pick arrow character
char arrow;
if (currentDirection == "Flat") arrow = 139;
else if (currentDirection == "SingleUp") arrow = 134;
else if (currentDirection == "DoubleUp") arrow = 135;
else if (currentDirection == "SingleDown") arrow = 136;
else if (currentDirection == "DoubleDown") arrow = 137;
else if (currentDirection == "FortyFiveUp") arrow = 138;
else if (currentDirection == "FortyFiveDown") arrow = 140;
else arrow = '?';
// Build display text
String displayText = "";
// ADD crossed digits
if (isOutdated) {
String glucoseStr = String(currentGlucose);
for (int i = 0; i < glucoseStr.length(); i++) {
if (isDigit(glucoseStr[i])) {
int num = glucoseStr[i] - '0'; // 09
glucoseStr[i] = 195 + ((num + 9) % 10); // Maps 0→204, 1→195, ...
}
}
String separatedStr = "";
for (int i = 0; i < glucoseStr.length(); i++) {
separatedStr += glucoseStr[i];
if (i < glucoseStr.length() - 1) {
separatedStr += char(255); // insert separator between digits
}
}
displayText += char(255);
displayText += char(255);
displayText += separatedStr;
displayText += char(255);
displayText += char(255);
displayText += " "; // extra space
displayText += arrow;
P.setCharSpacing(0);
} else {
displayText += String(currentGlucose) + String(arrow);
P.setCharSpacing(1);
}
P.setTextAlignment(PA_CENTER);
P.print(displayText.c_str());
delay(weatherDuration);
advanceDisplayMode();
return;
} else {
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(0);
P.print(F("())"));
delay(2000);
advanceDisplayMode();
return;
}
}
//DATE Display Mode
else if (displayMode == 5 && showDate) {
// --- VALID DATE CHECK ---
if (timeinfo.tm_year < 120 || timeinfo.tm_mday <= 0 || timeinfo.tm_mon < 0 || timeinfo.tm_mon > 11) {
advanceDisplayMode();
return; // skip drawing
}
// -------------------------
String dateString;
// Get localized month names
const char *const *months = getMonthsOfYear(language);
String monthAbbr = String(months[timeinfo.tm_mon]).substring(0, 5);
monthAbbr.toLowerCase();
// Add spaces between day digits
String dayString = String(timeinfo.tm_mday);
String spacedDay = "";
for (size_t i = 0; i < dayString.length(); i++) {
spacedDay += dayString[i];
if (i < dayString.length() - 1) spacedDay += " ";
}
// Function to check if day should come first for given language
auto isDayFirst = [](const String &lang) {
// Languages with DD-MM order
const char *dayFirstLangs[] = {
"af", // Afrikaans
"cs", // Czech
"da", // Danish
"de", // German
"eo", // Esperanto
"es", // Spanish
"et", // Estonian
"fi", // Finnish
"fr", // French
"ga", // Irish
"hr", // Croatian
"hu", // Hungarian
"it", // Italian
"lt", // Lithuanian
"lv", // Latvian
"nl", // Dutch
"no", // Norwegian
"pl", // Polish
"pt", // Portuguese
"ro", // Romanian
"ru", // Russian
"sk", // Slovak
"sl", // Slovenian
"sr", // Serbian
"sv", // Swedish
"sw", // Swahili
"tr" // Turkish
};
for (auto lf : dayFirstLangs) {
if (lang.equalsIgnoreCase(lf)) {
return true;
}
}
return false;
};
String langForDate = String(language);
if (langForDate == "ja") {
// Japanese: month number (spaced digits) + day + symbol
String spacedMonth = "";
String monthNum = String(timeinfo.tm_mon + 1);
dateString = monthAbbr + " " + spacedDay + " ±";
} else {
if (isDayFirst(language)) {
dateString = spacedDay + " " + monthAbbr;
} else {
dateString = monthAbbr + " " + spacedDay;
}
}
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(0);
P.print(dateString);
if (millis() - lastSwitch > weatherDuration) {
advanceDisplayMode();
}
}
// --- Custom Message Display Mode (displayMode == 6) ---
if (displayMode == 6) {
// 1. Initial Check: If message is empty, skip mode 6.
if (strlen(customMessage) == 0) {
advanceDisplayMode();
yield();
return;
}
// --- CHARACTER REPLACEMENT AND PADDING (Common to both short and long) ---
const size_t MAX_NON_SCROLLING_CHARS = 8;
String msg = String(customMessage);
// Replace standard digits 09 with your custom font character codes
for (int i = 0; i < msg.length(); i++) {
if (isDigit(msg[i])) {
int num = msg[i] - '0';
msg[i] = 145 + ((num + 9) % 10);
}
}
// --- CHECK FOR TIMEOUT (Applies to temporary short & long messages) ---
bool timedOut = false;
// Check if a time limit (messageDisplaySeconds > 0) has been exceeded
if (messageDisplaySeconds > 0 && (millis() - messageStartTime) >= (messageDisplaySeconds * 1000UL)) {
Serial.printf("[MESSAGE] HA message timed out after %d seconds.\n", messageDisplaySeconds);
timedOut = true;
}
// --- CHECK FOR SCROLL/CYCLE LIMIT BEFORE DISPLAYING ---
// Scrolls complete applies to long messages.
bool scrollsComplete = (messageScrollTimes > 0) && (currentScrollCount >= messageScrollTimes);
// Cycles complete applies to short messages.
extern int currentDisplayCycleCount; // Use the dedicated short message counter
bool cyclesComplete = (messageScrollTimes > 0) && (currentDisplayCycleCount >= messageScrollTimes);
// --- ADVANCE MODE CHECK (Check if HA parameters are complete) ---
// If either timer or cycle/scroll count is finished, we clean up the temporary message.
if (scrollsComplete || cyclesComplete) {
Serial.println(F("[MESSAGE] HA-controlled message finished."));
// Reset common counters
currentScrollCount = 0;
messageStartTime = 0;
currentDisplayCycleCount = 0; // Reset the cycle counter
// CRITICAL LOGIC: RESTORE PERSISTENT MESSAGE (Exit Mode 6 Logic)
if (strlen(lastPersistentMessage) > 0) {
// A persistent message exists, restore it
strncpy(customMessage, lastPersistentMessage, sizeof(customMessage));
messageScrollSpeed = GENERAL_SCROLL_SPEED;
messageDisplaySeconds = 0;
messageScrollTimes = 0;
Serial.printf("[MESSAGE] Restored persistent message: '%s'. Staying in mode 6.\n", customMessage);
} else {
// No persistent message to restore. Clear the temporary HA message and Exit mode 6.
customMessage[0] = '\0';
Serial.println(F("[MESSAGE] No persistent message to restore. Advancing display mode."));
advanceDisplayMode();
}
yield();
return;
}
// ----------------------------------------------------------------------
// BRANCH A: NON-SCROLLING (Short Message: strlen <= 8)
// ----------------------------------------------------------------------
if (msg.length() <= MAX_NON_SCROLLING_CHARS) {
// Determine the duration: use HA seconds if set, otherwise use weatherDuration.
unsigned long durationMs = (messageDisplaySeconds > 0)
? (messageDisplaySeconds * 1000UL)
: weatherDuration;
// If HA seconds is set, we use the timedOut check at the top.
// If only scrollTimes is set, we still display for weatherDuration before incrementing the cycle count.
Serial.printf("[MESSAGE] Displaying timed short message: '%s' for %lu ms. Advancing mode.\n", customMessage, durationMs);
P.setTextAlignment(PA_CENTER);
P.setCharSpacing(1);
P.print(msg.c_str());
// Block execution for the specified duration (non-HA uses weatherDuration)
unsigned long displayUntil = millis() + durationMs;
while (millis() < displayUntil) {
yield();
}
// --- CYCLE TRACKING FOR SCROLLTIMES ---
// Increment the counter if the HA message is configured to clear by scroll count.
if (messageScrollTimes > 0) {
currentDisplayCycleCount++;
Serial.printf("[MESSAGE] Short message cycle complete. Count: %d/%d\n", currentDisplayCycleCount, messageScrollTimes);
}
// After display, the message content must persist, but the display must cycle.
Serial.println(F("[MESSAGE] Short message duration complete. Advancing display mode."));
advanceDisplayMode();
yield();
return;
}
// ----------------------------------------------------------------------
// BRANCH B: SCROLLING (Long Message: strlen > 8) - (Existing Logic)
// ----------------------------------------------------------------------
// --- Determine if we need left padding based on previous mode ---
bool addPadding = false;
bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0;
// If coming from CLOCK mode
if (prevDisplayMode == 0 && (showDayOfWeek || colonBlinkEnabled)) {
addPadding = true;
} else if (prevDisplayMode == 1 && humidityVisible) {
addPadding = true;
}
// Apply padding (4 spaces) if needed
if (addPadding) {
msg = " " + msg;
}
// --- Display scrolling message ---
P.setTextAlignment(PA_LEFT);
P.setCharSpacing(1);
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
extern int messageScrollSpeed;
// START SCROLL CYCLE
P.displayScroll(msg.c_str(), PA_LEFT, actualScrollDirection, messageScrollSpeed);
// BLOCKING WAIT: Completes 1 full scroll
while (!P.displayAnimate()) yield();
// SCROLL COUNT INCREMENT
if (messageScrollTimes > 0) {
currentScrollCount++;
Serial.printf("[MESSAGE] Scroll complete. Count: %d/%d\n", currentScrollCount, messageScrollTimes);
}
// If no HA parameters are set, this is a persistent/infinite scroll, so advance mode after 1 scroll cycle.
// If HA parameters ARE set, the mode relies on the check at the top to break out.
if (messageDisplaySeconds == 0 && messageScrollTimes == 0) {
P.setTextAlignment(PA_CENTER);
advanceDisplayMode();
}
yield();
return;
}
unsigned long currentMillis = millis();
unsigned long runtimeSeconds = (currentMillis - bootMillis) / 1000;
unsigned long currentTotal = totalUptimeSeconds + runtimeSeconds;
// --- Log and save uptime every 10 minutes ---
const unsigned long uptimeLogInterval = 600000UL; // 10 minutes in ms
if (currentMillis - lastUptimeLog >= uptimeLogInterval) {
lastUptimeLog = currentMillis;
Serial.printf("[UPTIME] Runtime: %s (total %.2f hours)\n",
formatUptime(currentTotal).c_str(), currentTotal / 3600.0);
saveUptime(); // Save accumulated uptime every 10 minutes
}
yield();
}