mirror of
https://github.com/mfactory-osaka/ESPTimeCast.git
synced 2026-02-19 11:54:56 -05:00
1337 lines
44 KiB
C++
1337 lines
44 KiB
C++
#include <Arduino.h>
|
||
#include <ESP8266WiFi.h>
|
||
#include <LittleFS.h>
|
||
#include <ArduinoJson.h>
|
||
#include <MD_Parola.h>
|
||
#include <MD_MAX72XX.h>
|
||
#include <SPI.h>
|
||
#include <ESPAsyncTCP.h>
|
||
#include <ESPAsyncWebServer.h>
|
||
#include <DNSServer.h>
|
||
#include <sntp.h>
|
||
#include <time.h>
|
||
|
||
#include "mfactoryfont.h" // Custom font
|
||
#include "tz_lookup.h" // Timezone lookup, do not duplicate mapping here!
|
||
#include "days_lookup.h" // Languages for the Days of the Week
|
||
|
||
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
|
||
#define MAX_DEVICES 4
|
||
#define CLK_PIN 12
|
||
#define DATA_PIN 15
|
||
#define CS_PIN 13
|
||
|
||
MD_Parola P = MD_Parola(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES);
|
||
AsyncWebServer server(80);
|
||
|
||
// WiFi and configuration globals
|
||
char ssid[32] = "";
|
||
char password[32] = "";
|
||
char openWeatherApiKey[64] = "";
|
||
char openWeatherCity[64] = "";
|
||
char openWeatherCountry[64] = "";
|
||
char weatherUnits[12] = "metric";
|
||
char timeZone[64] = "";
|
||
char language[8] = "en";
|
||
String mainDesc = "";
|
||
String detailedDesc = "";
|
||
|
||
// Timing and display settings
|
||
unsigned long clockDuration = 10000;
|
||
unsigned long weatherDuration = 5000;
|
||
int brightness = 7;
|
||
bool flipDisplay = false;
|
||
bool twelveHourToggle = false;
|
||
bool showDayOfWeek = true;
|
||
bool showHumidity = false;
|
||
char ntpServer1[64] = "pool.ntp.org";
|
||
char ntpServer2[64] = "time.nist.gov";
|
||
|
||
// Dimming
|
||
bool dimmingEnabled = false;
|
||
int dimStartHour = 18; // 6pm default
|
||
int dimStartMinute = 0;
|
||
int dimEndHour = 8; // 8am default
|
||
int dimEndMinute = 0;
|
||
int dimBrightness = 2; // Dimming level (0-15)
|
||
|
||
// 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; // Flag to trigger immediate weather fetch
|
||
|
||
unsigned long lastSwitch = 0;
|
||
unsigned long lastColonBlink = 0;
|
||
int displayMode = 0;
|
||
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;
|
||
|
||
// Non-blocking IP display globals
|
||
bool showingIp = false;
|
||
int ipDisplayCount = 0;
|
||
const int ipDisplayMax = 1;
|
||
String pendingIpToShow = "";
|
||
|
||
// 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..."));
|
||
|
||
if (!LittleFS.exists("/config.json")) {
|
||
Serial.println(F("[CONFIG] config.json not found, creating with defaults..."));
|
||
DynamicJsonDocument doc(512);
|
||
doc[F("ssid")] = "";
|
||
doc[F("password")] = "";
|
||
doc[F("openWeatherApiKey")] = "";
|
||
doc[F("openWeatherCity")] = "";
|
||
doc[F("openWeatherCountry")] = "";
|
||
doc[F("weatherUnits")] = "metric";
|
||
doc[F("clockDuration")] = 10000;
|
||
doc[F("weatherDuration")] = 5000;
|
||
doc[F("timeZone")] = "";
|
||
doc[F("language")] = "en";
|
||
doc[F("brightness")] = brightness;
|
||
doc[F("flipDisplay")] = flipDisplay;
|
||
doc[F("twelveHourToggle")] = twelveHourToggle;
|
||
doc[F("showDayOfWeek")] = showDayOfWeek;
|
||
doc[F("showHumidity")] = showHumidity;
|
||
doc[F("ntpServer1")] = ntpServer1;
|
||
doc[F("ntpServer2")] = ntpServer2;
|
||
doc[F("dimmingEnabled")] = dimmingEnabled;
|
||
doc[F("dimStartHour")] = dimStartHour;
|
||
doc[F("dimEndHour")] = dimEndHour;
|
||
doc[F("dimBrightness")] = dimBrightness;
|
||
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"));
|
||
}
|
||
}
|
||
|
||
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(2048);
|
||
DeserializationError error = deserializeJson(doc, configFile);
|
||
configFile.close();
|
||
|
||
if (error) {
|
||
Serial.print(F("[ERROR] JSON parse failed during load: "));
|
||
Serial.println(error.f_str());
|
||
return;
|
||
}
|
||
|
||
strlcpy(ssid, doc["ssid"] | "", sizeof(ssid));
|
||
strlcpy(password, doc["password"] | "", sizeof(password));
|
||
strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey));
|
||
strlcpy(openWeatherCity, doc["openWeatherCity"] | "", sizeof(openWeatherCity));
|
||
strlcpy(openWeatherCountry, doc["openWeatherCountry"] | "", sizeof(openWeatherCountry));
|
||
strlcpy(weatherUnits, doc["weatherUnits"] | "metric", sizeof(weatherUnits));
|
||
clockDuration = doc["clockDuration"] | 10000;
|
||
weatherDuration = doc["weatherDuration"] | 5000;
|
||
strlcpy(timeZone, doc["timeZone"] | "Etc/UTC", sizeof(timeZone));
|
||
if (doc.containsKey("language")) {
|
||
strlcpy(language, doc["language"], sizeof(language));
|
||
} else {
|
||
strlcpy(language, "en", sizeof(language));
|
||
Serial.println(F("[CONFIG] 'language' key not found in config.json, defaulting to 'en'."));
|
||
}
|
||
|
||
brightness = doc["brightness"] | 7;
|
||
flipDisplay = doc["flipDisplay"] | false;
|
||
twelveHourToggle = doc["twelveHourToggle"] | false;
|
||
showDayOfWeek = doc["showDayOfWeek"] | true;
|
||
showHumidity = doc["showHumidity"] | false;
|
||
|
||
String de = doc["dimmingEnabled"].as<String>();
|
||
dimmingEnabled = (de == "true" || de == "on" || de == "1");
|
||
|
||
dimStartHour = doc["dimStartHour"] | 18;
|
||
dimStartMinute = doc["dimStartMinute"] | 0;
|
||
dimEndHour = doc["dimEndHour"] | 8;
|
||
dimEndMinute = doc["dimEndMinute"] | 0;
|
||
dimBrightness = doc["dimBrightness"] | 0;
|
||
|
||
strlcpy(ntpServer1, doc["ntpServer1"] | "pool.ntp.org", sizeof(ntpServer1));
|
||
strlcpy(ntpServer2, doc["ntpServer2"] | "time.nist.gov", sizeof(ntpServer2));
|
||
|
||
if (strcmp(weatherUnits, "imperial") == 0)
|
||
tempSymbol = ']';
|
||
else
|
||
tempSymbol = '[';
|
||
Serial.println(F("[CONFIG] Configuration loaded."));
|
||
|
||
if (doc.containsKey("showWeatherDescription"))
|
||
showWeatherDescription = doc["showWeatherDescription"];
|
||
else
|
||
showWeatherDescription = false;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// WiFi Setup
|
||
// -----------------------------------------------------------------------------
|
||
const char *DEFAULT_AP_PASSWORD = "12345678";
|
||
const char *AP_SSID = "ESPTimeCast";
|
||
|
||
void connectWiFi() {
|
||
Serial.println(F("[WIFI] Connecting to WiFi..."));
|
||
|
||
bool credentialsExist = (strlen(ssid) > 0);
|
||
|
||
if (!credentialsExist) {
|
||
Serial.println(F("[WIFI] No saved credentials. Starting AP mode directly."));
|
||
WiFi.mode(WIFI_AP);
|
||
WiFi.disconnect(true);
|
||
delay(100);
|
||
|
||
if (strlen(DEFAULT_AP_PASSWORD) < 8) {
|
||
WiFi.softAP(AP_SSID);
|
||
Serial.println(F("[WIFI] AP Mode started (no password, too short)."));
|
||
} else {
|
||
WiFi.softAP(AP_SSID, DEFAULT_AP_PASSWORD);
|
||
Serial.println(F("[WIFI] AP Mode started."));
|
||
}
|
||
|
||
IPAddress apIP(192, 168, 4, 1);
|
||
WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0));
|
||
dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
|
||
Serial.print(F("AP IP address: "));
|
||
Serial.println(WiFi.softAPIP());
|
||
isAPMode = true;
|
||
Serial.println(F("[WIFI] AP Mode Started"));
|
||
return;
|
||
}
|
||
|
||
WiFi.disconnect(true);
|
||
delay(100);
|
||
WiFi.begin(ssid, password);
|
||
unsigned long startAttemptTime = millis();
|
||
const unsigned long timeout = 25000;
|
||
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;
|
||
animating = false;
|
||
|
||
pendingIpToShow = WiFi.localIP().toString();
|
||
showingIp = true;
|
||
ipDisplayCount = 0;
|
||
P.displayClear();
|
||
P.setCharSpacing(1);
|
||
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
|
||
P.displayScroll(pendingIpToShow.c_str(), PA_CENTER, actualScrollDirection, 120);
|
||
break;
|
||
} else if (now - startAttemptTime >= timeout) {
|
||
Serial.println(F("\r\n[WiFi] Failed. Starting AP mode..."));
|
||
WiFi.softAP(AP_SSID, DEFAULT_AP_PASSWORD);
|
||
Serial.print(F("AP IP address: "));
|
||
Serial.println(WiFi.softAPIP());
|
||
dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
|
||
isAPMode = true;
|
||
animating = false;
|
||
Serial.println(F("[WIFI] AP Mode Started"));
|
||
break;
|
||
}
|
||
if (now - animTimer > 750) {
|
||
animTimer = now;
|
||
P.setTextAlignment(PA_CENTER);
|
||
switch (animFrame % 3) {
|
||
case 0: P.print(F("# ©")); break;
|
||
case 1: P.print(F("# ª")); break;
|
||
case 2: P.print(F("# «")); break;
|
||
}
|
||
animFrame++;
|
||
}
|
||
yield();
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Time / NTP Functions
|
||
// -----------------------------------------------------------------------------
|
||
void setupTime() {
|
||
sntp_stop();
|
||
if (!isAPMode) {
|
||
Serial.println(F("[TIME] Starting NTP sync..."));
|
||
}
|
||
configTime(0, 0, ntpServer1, ntpServer2);
|
||
setenv("TZ", ianaToPosix(timeZone), 1);
|
||
tzset();
|
||
ntpState = NTP_SYNCING;
|
||
ntpStartTime = millis();
|
||
ntpRetryCount = 0;
|
||
ntpSyncSuccessful = false;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Utility
|
||
// -----------------------------------------------------------------------------
|
||
void printConfigToSerial() {
|
||
Serial.println(F("========= Loaded Configuration ========="));
|
||
Serial.print(F("WiFi SSID: "));
|
||
Serial.println(ssid);
|
||
Serial.print(F("WiFi Password: "));
|
||
Serial.println(password);
|
||
Serial.print(F("OpenWeather City: "));
|
||
Serial.println(openWeatherCity);
|
||
Serial.print(F("OpenWeather Country: "));
|
||
Serial.println(openWeatherCountry);
|
||
Serial.print(F("OpenWeather API Key: "));
|
||
Serial.println(openWeatherApiKey);
|
||
Serial.print(F("Temperature Unit: "));
|
||
Serial.println(weatherUnits);
|
||
Serial.print(F("Clock duration: "));
|
||
Serial.println(clockDuration);
|
||
Serial.print(F("Weather duration: "));
|
||
Serial.println(weatherDuration);
|
||
Serial.print(F("TimeZone (IANA): "));
|
||
Serial.println(timeZone);
|
||
Serial.print(F("Days of the Week/Weather description language: "));
|
||
Serial.println(language);
|
||
Serial.print(F("Brightness: "));
|
||
Serial.println(brightness);
|
||
Serial.print(F("Flip Display: "));
|
||
Serial.println(flipDisplay ? "Yes" : "No");
|
||
Serial.print(F("Show 12h Clock: "));
|
||
Serial.println(twelveHourToggle ? "Yes" : "No");
|
||
Serial.print(F("Show Day of the Week: "));
|
||
Serial.println(showDayOfWeek ? "Yes" : "No");
|
||
Serial.print(F("Show Weather Description: "));
|
||
Serial.println(showWeatherDescription ? "Yes" : "No");
|
||
Serial.print(F("Show Humidity "));
|
||
Serial.println(showHumidity ? "Yes" : "No");
|
||
Serial.print(F("NTP Server 1: "));
|
||
Serial.println(ntpServer1);
|
||
Serial.print(F("NTP Server 2: "));
|
||
Serial.println(ntpServer2);
|
||
Serial.print(F("Dimming Enabled: "));
|
||
Serial.println(dimmingEnabled);
|
||
Serial.print(F("Dimming Start Hour: "));
|
||
Serial.println(dimStartHour);
|
||
Serial.print(F("Dimming Start Minute: "));
|
||
Serial.println(dimStartMinute);
|
||
Serial.print(F("Dimming End Hour: "));
|
||
Serial.println(dimEndHour);
|
||
Serial.print(F("Dimming End Minute: "));
|
||
Serial.println(dimEndMinute);
|
||
Serial.print(F("Dimming Brightness: "));
|
||
Serial.println(dimBrightness);
|
||
Serial.println(F("========================================"));
|
||
Serial.println();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Web Server and Captive Portal
|
||
// -----------------------------------------------------------------------------
|
||
void handleCaptivePortal(AsyncWebServerRequest *request);
|
||
|
||
void setupWebServer() {
|
||
Serial.println(F("[WEBSERVER] Setting up web server..."));
|
||
|
||
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||
Serial.println(F("[WEBSERVER] Request: /"));
|
||
request->send(LittleFS, "/index.html", "text/html");
|
||
});
|
||
|
||
server.on("/config.json", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||
Serial.println(F("[WEBSERVER] Request: /config.json"));
|
||
File f = LittleFS.open("/config.json", "r");
|
||
if (!f) {
|
||
Serial.println(F("[WEBSERVER] Error opening /config.json"));
|
||
request->send(500, "application/json", "{\"error\":\"Failed to open config.json\"}");
|
||
return;
|
||
}
|
||
DynamicJsonDocument doc(2048);
|
||
DeserializationError err = deserializeJson(doc, f);
|
||
f.close();
|
||
if (err) {
|
||
Serial.print(F("[WEBSERVER] Error parsing /config.json: "));
|
||
Serial.println(err.f_str());
|
||
request->send(500, "application/json", "{\"error\":\"Failed to parse config.json\"}");
|
||
return;
|
||
}
|
||
doc[F("mode")] = isAPMode ? "ap" : "sta";
|
||
String response;
|
||
serializeJson(doc, response);
|
||
request->send(200, "application/json", response);
|
||
});
|
||
|
||
// Save, restore, status and settings handlers grouped for clarity
|
||
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..."));
|
||
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 (int i = 0; i < request->params(); i++) {
|
||
const AsyncWebParameter *p = request->getParam(i);
|
||
String n = p->name();
|
||
String v = p->value();
|
||
|
||
Serial.printf("[SAVE] Param: %s = %s\n", n.c_str(), v.c_str());
|
||
|
||
if (n == "brightness") doc[n] = v.toInt();
|
||
else if (n == "clockDuration") doc[n] = v.toInt();
|
||
else if (n == "weatherDuration") doc[n] = v.toInt();
|
||
else if (n == "flipDisplay") doc[n] = (v == "true" || v == "on" || v == "1");
|
||
else if (n == "twelveHourToggle") doc[n] = (v == "true" || v == "on" || v == "1");
|
||
else if (n == "showDayOfWeek") doc[n] = (v == "true" || v == "on" || v == "1");
|
||
else if (n == "showHumidity") doc[n] = (v == "true" || v == "on" || v == "1");
|
||
else if (n == "dimStartHour") doc[n] = v.toInt();
|
||
else if (n == "dimStartMinute") doc[n] = v.toInt();
|
||
else if (n == "dimEndHour") doc[n] = v.toInt();
|
||
else if (n == "dimEndMinute") doc[n] = v.toInt();
|
||
else if (n == "dimBrightness") doc[n] = v.toInt();
|
||
else if (n == "showWeatherDescription") doc[n] = (v == "true" || v == "on" || v == "1");
|
||
else doc[n] = v;
|
||
}
|
||
|
||
Serial.print(F("[SAVE] Document content before saving: "));
|
||
serializeJson(doc, Serial);
|
||
Serial.println();
|
||
|
||
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."));
|
||
|
||
Serial.println(F("[SAVE] Attempting to open /config.json for verification."));
|
||
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;
|
||
}
|
||
|
||
Serial.println(F("[SAVE] Content of /config.json during verification read:"));
|
||
while (verify.available()) {
|
||
Serial.write(verify.read());
|
||
}
|
||
Serial.println();
|
||
verify.seek(0);
|
||
|
||
DynamicJsonDocument test(2048);
|
||
DeserializationError err = deserializeJson(test, verify);
|
||
verify.close();
|
||
|
||
if (err) {
|
||
Serial.print(F("[SAVE] Config corrupted after save: "));
|
||
Serial.println(err.f_str());
|
||
DynamicJsonDocument errorDoc(256);
|
||
errorDoc[F("error")] = String("Config corrupted. Reboot cancelled. Error: ") + err.f_str();
|
||
String response;
|
||
serializeJson(errorDoc, response);
|
||
request->send(500, "application/json", response);
|
||
return;
|
||
}
|
||
|
||
Serial.println(F("[SAVE] Config verification successful."));
|
||
DynamicJsonDocument okDoc(128);
|
||
okDoc[F("message")] = "Saved successfully. Rebooting...";
|
||
String response;
|
||
serializeJson(okDoc, response);
|
||
request->send(200, "application/json", response);
|
||
Serial.println(F("[WEBSERVER] Sending success response and scheduling reboot..."));
|
||
|
||
request->onDisconnect([]() {
|
||
Serial.println(F("[WEBSERVER] Client disconnected, rebooting ESP..."));
|
||
ESP.restart();
|
||
});
|
||
});
|
||
|
||
server.on("/restore", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
Serial.println(F("[WEBSERVER] Request: /restore"));
|
||
if (LittleFS.exists("/config.bak")) {
|
||
File src = LittleFS.open("/config.bak", "r");
|
||
if (!src) {
|
||
Serial.println(F("[WEBSERVER] Failed to open /config.bak"));
|
||
DynamicJsonDocument errorDoc(128);
|
||
errorDoc[F("error")] = "Failed to open backup file.";
|
||
String response;
|
||
serializeJson(errorDoc, response);
|
||
request->send(500, "application/json", response);
|
||
return;
|
||
}
|
||
File dst = LittleFS.open("/config.json", "w");
|
||
if (!dst) {
|
||
src.close();
|
||
Serial.println(F("[WEBSERVER] Failed to open /config.json for writing"));
|
||
DynamicJsonDocument errorDoc(128);
|
||
errorDoc[F("error")] = "Failed to open config for writing.";
|
||
String response;
|
||
serializeJson(errorDoc, response);
|
||
request->send(500, "application/json", response);
|
||
return;
|
||
}
|
||
|
||
while (src.available()) {
|
||
dst.write(src.read());
|
||
}
|
||
src.close();
|
||
dst.close();
|
||
|
||
DynamicJsonDocument okDoc(128);
|
||
okDoc[F("message")] = "✅ Backup restored! Device will now reboot.";
|
||
String response;
|
||
serializeJson(okDoc, response);
|
||
request->send(200, "application/json", response);
|
||
request->onDisconnect([]() {
|
||
Serial.println(F("[WEBSERVER] Rebooting after restore..."));
|
||
ESP.restart();
|
||
});
|
||
|
||
} else {
|
||
Serial.println(F("[WEBSERVER] No backup found"));
|
||
DynamicJsonDocument errorDoc(128);
|
||
errorDoc[F("error")] = "No backup found.";
|
||
String response;
|
||
serializeJson(errorDoc, response);
|
||
request->send(404, "application/json", response);
|
||
}
|
||
});
|
||
|
||
server.on("/ap_status", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||
Serial.print(F("[WEBSERVER] Request: /ap_status. isAPMode = "));
|
||
Serial.println(isAPMode);
|
||
String json = "{\"isAP\": ";
|
||
json += (isAPMode) ? "true" : "false";
|
||
json += "}";
|
||
request->send(200, "application/json", json);
|
||
});
|
||
|
||
// Settings endpoints (brightness, flip, etc.)
|
||
server.on("/set_brightness", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
if (!request->hasParam("value", true)) {
|
||
request->send(400, "application/json", "{\"error\":\"Missing value\"}");
|
||
return;
|
||
}
|
||
int newBrightness = request->getParam("value", true)->value().toInt();
|
||
if (newBrightness < 0) newBrightness = 0;
|
||
if (newBrightness > 15) newBrightness = 15;
|
||
brightness = newBrightness;
|
||
P.setIntensity(brightness);
|
||
Serial.printf("[WEBSERVER] Set brightness to %d\n", brightness);
|
||
request->send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/set_flip", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
bool flip = false;
|
||
if (request->hasParam("value", true)) {
|
||
String v = request->getParam("value", true)->value();
|
||
flip = (v == "1" || v == "true" || v == "on");
|
||
}
|
||
flipDisplay = flip;
|
||
P.setZoneEffect(0, flipDisplay, PA_FLIP_UD);
|
||
P.setZoneEffect(0, flipDisplay, PA_FLIP_LR);
|
||
Serial.printf("[WEBSERVER] Set flipDisplay to %d\n", flipDisplay);
|
||
request->send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/set_twelvehour", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
bool twelveHour = false;
|
||
if (request->hasParam("value", true)) {
|
||
String v = request->getParam("value", true)->value();
|
||
twelveHour = (v == "1" || v == "true" || v == "on");
|
||
}
|
||
twelveHourToggle = twelveHour;
|
||
Serial.printf("[WEBSERVER] Set twelveHourToggle to %d\n", twelveHourToggle);
|
||
request->send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/set_dayofweek", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
bool showDay = false;
|
||
if (request->hasParam("value", true)) {
|
||
String v = request->getParam("value", true)->value();
|
||
showDay = (v == "1" || v == "true" || v == "on");
|
||
}
|
||
showDayOfWeek = showDay;
|
||
Serial.printf("[WEBSERVER] Set showDayOfWeek to %d\n", showDayOfWeek);
|
||
request->send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/set_humidity", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
bool showHumidityNow = false;
|
||
if (request->hasParam("value", true)) {
|
||
String v = request->getParam("value", true)->value();
|
||
showHumidityNow = (v == "1" || v == "true" || v == "on");
|
||
}
|
||
showHumidity = showHumidityNow;
|
||
Serial.printf("[WEBSERVER] Set showHumidity to %d\n", showHumidity);
|
||
request->send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/set_language", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
if (!request->hasParam("value", true)) {
|
||
request->send(400, "application/json", "{\"error\":\"Missing value\"}");
|
||
return;
|
||
}
|
||
String lang = request->getParam("value", true)->value();
|
||
strlcpy(language, lang.c_str(), sizeof(language));
|
||
Serial.printf("[WEBSERVER] Set language to %s\n", language);
|
||
shouldFetchWeatherNow = true;
|
||
request->send(200, "application/json", "{\"ok\":true}");
|
||
});
|
||
|
||
server.on("/set_weatherdesc", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||
bool showDesc = false;
|
||
if (request->hasParam("value", true)) {
|
||
String v = request->getParam("value", true)->value();
|
||
showDesc = (v == "1" || v == "true" || v == "on");
|
||
}
|
||
showWeatherDescription = showDesc;
|
||
Serial.printf("[WEBSERVER] Set showWeatherDescription 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 = ']'; // Fahrenheit symbol
|
||
} else {
|
||
strcpy(weatherUnits, "metric");
|
||
tempSymbol = '['; // Celsius symbol
|
||
}
|
||
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.begin();
|
||
Serial.println(F("[WEBSERVER] Web server started"));
|
||
}
|
||
|
||
void handleCaptivePortal(AsyncWebServerRequest *request) {
|
||
Serial.print(F("[WEBSERVER] Captive Portal Redirecting: "));
|
||
Serial.println(request->url());
|
||
request->redirect(String("http://") + WiFi.softAPIP().toString() + "/");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Weather Fetching and API settings
|
||
// -----------------------------------------------------------------------------
|
||
String normalizeWeatherDescription(String str) {
|
||
str.replace("å", "a");
|
||
str.replace("ä", "a");
|
||
str.replace("à", "a");
|
||
str.replace("á", "a");
|
||
str.replace("â", "a");
|
||
str.replace("ã", "a");
|
||
str.replace("ā", "a");
|
||
str.replace("ă", "a");
|
||
str.replace("ą", "a");
|
||
|
||
str.replace("æ", "ae");
|
||
|
||
str.replace("ç", "c");
|
||
str.replace("č", "c");
|
||
str.replace("ć", "c");
|
||
|
||
str.replace("ď", "d");
|
||
|
||
str.replace("é", "e");
|
||
str.replace("è", "e");
|
||
str.replace("ê", "e");
|
||
str.replace("ë", "e");
|
||
str.replace("ē", "e");
|
||
str.replace("ė", "e");
|
||
str.replace("ę", "e");
|
||
|
||
str.replace("ğ", "g");
|
||
str.replace("ģ", "g");
|
||
|
||
str.replace("ĥ", "h");
|
||
|
||
str.replace("í", "i");
|
||
str.replace("ì", "i");
|
||
str.replace("î", "i");
|
||
str.replace("ï", "i");
|
||
str.replace("ī", "i");
|
||
str.replace("į", "i");
|
||
|
||
str.replace("ĵ", "j");
|
||
|
||
str.replace("ķ", "k");
|
||
|
||
str.replace("ľ", "l");
|
||
str.replace("ł", "l");
|
||
|
||
str.replace("ñ", "n");
|
||
str.replace("ń", "n");
|
||
str.replace("ņ", "n");
|
||
|
||
str.replace("ó", "o");
|
||
str.replace("ò", "o");
|
||
str.replace("ô", "o");
|
||
str.replace("ö", "o");
|
||
str.replace("õ", "o");
|
||
str.replace("ø", "o");
|
||
str.replace("ō", "o");
|
||
str.replace("ő", "o");
|
||
|
||
str.replace("œ", "oe");
|
||
|
||
str.replace("ŕ", "r");
|
||
|
||
str.replace("ś", "s");
|
||
str.replace("š", "s");
|
||
str.replace("ș", "s");
|
||
str.replace("ŝ", "s");
|
||
|
||
str.replace("ß", "ss");
|
||
|
||
str.replace("ť", "t");
|
||
str.replace("ț", "t");
|
||
|
||
str.replace("ú", "u");
|
||
str.replace("ù", "u");
|
||
str.replace("û", "u");
|
||
str.replace("ü", "u");
|
||
str.replace("ū", "u");
|
||
str.replace("ů", "u");
|
||
str.replace("ű", "u");
|
||
|
||
str.replace("ŵ", "w");
|
||
|
||
str.replace("ý", "y");
|
||
str.replace("ÿ", "y");
|
||
str.replace("ŷ", "y");
|
||
|
||
str.replace("ž", "z");
|
||
str.replace("ź", "z");
|
||
str.replace("ż", "z");
|
||
|
||
str.toLowerCase();
|
||
// Filter out anything that's not a–z or space
|
||
String result = "";
|
||
for (unsigned int i = 0; i < str.length(); i++) {
|
||
char c = str.charAt(i);
|
||
if ((c >= 'a' && c <= 'z') || c == ' ') {
|
||
result += c;
|
||
}
|
||
// else: ignore punctuation, emoji, symbols
|
||
}
|
||
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;
|
||
}
|
||
|
||
String buildWeatherURL() {
|
||
String base = "http://api.openweathermap.org/data/2.5/weather?";
|
||
|
||
float lat = atof(openWeatherCity);
|
||
float lon = atof(openWeatherCountry);
|
||
|
||
bool latValid = isNumber(openWeatherCity) && isNumber(openWeatherCountry) && lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0;
|
||
|
||
if (latValid) {
|
||
base += "lat=" + String(lat, 8) + "&lon=" + String(lon, 8);
|
||
} else if (isFiveDigitZip(openWeatherCity) && String(openWeatherCountry).equalsIgnoreCase("US")) {
|
||
base += "zip=" + String(openWeatherCity) + "," + String(openWeatherCountry);
|
||
} else {
|
||
base += "q=" + String(openWeatherCity) + "," + String(openWeatherCountry);
|
||
}
|
||
|
||
base += "&appid=" + String(openWeatherApiKey);
|
||
base += "&units=" + String(weatherUnits);
|
||
|
||
String langForAPI = String(language); // Start with the global language
|
||
|
||
if (langForAPI == "eo" || langForAPI == "sw" || langForAPI == "ja") {
|
||
langForAPI = "en"; // Override to "en" for the API
|
||
}
|
||
base += "&lang=" + langForAPI;
|
||
|
||
return base;
|
||
}
|
||
|
||
void fetchWeather() {
|
||
Serial.println(F("[WEATHER] Fetching weather data..."));
|
||
if (WiFi.status() != WL_CONNECTED) {
|
||
Serial.println(F("[WEATHER] Skipped: WiFi not connected"));
|
||
weatherAvailable = false;
|
||
weatherFetched = false;
|
||
return;
|
||
}
|
||
if (!openWeatherApiKey || strlen(openWeatherApiKey) != 32) {
|
||
Serial.println(F("[WEATHER] Skipped: Invalid API key (must be exactly 32 characters)"));
|
||
weatherAvailable = false;
|
||
weatherFetched = false;
|
||
return;
|
||
}
|
||
if (!(strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0)) {
|
||
Serial.println(F("[WEATHER] Skipped: City or Country is empty."));
|
||
return;
|
||
}
|
||
|
||
Serial.println(F("[WEATHER] Connecting to OpenWeatherMap..."));
|
||
const char *host = "api.openweathermap.org";
|
||
String url = buildWeatherURL();
|
||
Serial.println(F("[WEATHER] URL: ") + url);
|
||
|
||
IPAddress ip;
|
||
if (!WiFi.hostByName(host, ip)) {
|
||
Serial.println(F("[WEATHER] DNS lookup failed!"));
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
|
||
if (!client.connect(host, 80)) {
|
||
Serial.println(F("[WEATHER] Connection failed"));
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
|
||
Serial.println(F("[WEATHER] Connected, sending request..."));
|
||
String request = String("GET ") + url + " HTTP/1.1\r\n" + F("Host: ") + host + F("\r\n") + F("Connection: close\r\n\r\n");
|
||
|
||
if (!client.print(request)) {
|
||
Serial.println(F("[WEATHER] Failed to send request!"));
|
||
client.stop();
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
|
||
unsigned long weatherStart = millis();
|
||
const unsigned long weatherTimeout = 10000;
|
||
|
||
bool isBody = false;
|
||
String payload = "";
|
||
String line = "";
|
||
|
||
while ((client.connected() || client.available()) && millis() - weatherStart < weatherTimeout && WiFi.status() == WL_CONNECTED) {
|
||
line = client.readStringUntil('\n');
|
||
if (line.length() == 0) continue;
|
||
|
||
if (line.startsWith(F("HTTP/1.1"))) {
|
||
int statusCode = line.substring(9, 12).toInt();
|
||
if (statusCode != 200) {
|
||
Serial.print(F("[WEATHER] HTTP error: "));
|
||
Serial.println(statusCode);
|
||
client.stop();
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!isBody && line == F("\r")) {
|
||
isBody = true;
|
||
while (client.available()) {
|
||
payload += (char)client.read();
|
||
}
|
||
break;
|
||
}
|
||
yield();
|
||
}
|
||
client.stop();
|
||
|
||
if (millis() - weatherStart >= weatherTimeout) {
|
||
Serial.println(F("[WEATHER] ERROR: Weather fetch timed out!"));
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
|
||
Serial.println(F("[WEATHER] Response received."));
|
||
Serial.println(F("[WEATHER] Payload: ") + payload);
|
||
|
||
DynamicJsonDocument doc(2048);
|
||
DeserializationError error = deserializeJson(doc, payload);
|
||
if (error) {
|
||
Serial.print(F("[WEATHER] JSON parse error: "));
|
||
Serial.println(error.f_str());
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
|
||
if (doc.containsKey(F("main")) && doc[F("main")].containsKey(F("temp"))) {
|
||
float temp = doc[F("main")][F("temp")];
|
||
currentTemp = String((int)round(temp)) + "º";
|
||
Serial.printf("[WEATHER] Temp: %s\n", currentTemp.c_str());
|
||
weatherAvailable = true;
|
||
} else {
|
||
Serial.println(F("[WEATHER] Temperature not found in JSON payload"));
|
||
weatherAvailable = false;
|
||
return;
|
||
}
|
||
|
||
if (doc.containsKey(F("main")) && doc[F("main")].containsKey(F("humidity"))) {
|
||
currentHumidity = doc[F("main")][F("humidity")];
|
||
Serial.printf("[WEATHER] Humidity: %d%%\n", currentHumidity);
|
||
} else {
|
||
currentHumidity = -1;
|
||
}
|
||
|
||
if (doc.containsKey(F("weather")) && doc[F("weather")].is<JsonArray>()) {
|
||
JsonObject weatherObj = doc[F("weather")][0];
|
||
if (weatherObj.containsKey(F("main"))) {
|
||
mainDesc = weatherObj[F("main")].as<String>();
|
||
}
|
||
if (weatherObj.containsKey(F("description"))) {
|
||
detailedDesc = weatherObj[F("description")].as<String>();
|
||
}
|
||
} else {
|
||
Serial.println(F("[WEATHER] Weather description not found in JSON payload"));
|
||
}
|
||
|
||
weatherDescription = normalizeWeatherDescription(detailedDesc);
|
||
|
||
Serial.printf("[WEATHER] Description used: %s\n", weatherDescription.c_str());
|
||
|
||
weatherFetched = true;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Main setup() and loop()
|
||
// -----------------------------------------------------------------------------
|
||
/*
|
||
DisplayMode key:
|
||
0: Clock
|
||
1: Weather
|
||
2: Weather Description
|
||
*/
|
||
unsigned long descStartTime = 0;
|
||
bool descScrolling = false;
|
||
const unsigned long descriptionDuration = 3000; // 3s for short text
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
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);
|
||
}
|
||
}
|
||
Serial.println(F("[SETUP] LittleFS file system mounted successfully."));
|
||
|
||
P.begin();
|
||
P.setCharSpacing(0);
|
||
P.setFont(mFactory);
|
||
loadConfig();
|
||
P.setIntensity(brightness);
|
||
P.setZoneEffect(0, flipDisplay, PA_FLIP_UD);
|
||
P.setZoneEffect(0, flipDisplay, PA_FLIP_LR);
|
||
Serial.println(F("[SETUP] Parola (LED Matrix) initialized"));
|
||
connectWiFi();
|
||
Serial.println(F("[SETUP] Wifi connected"));
|
||
setupWebServer();
|
||
Serial.println(F("[SETUP] Webserver setup complete"));
|
||
Serial.println(F("[SETUP] Setup complete"));
|
||
Serial.println();
|
||
printConfigToSerial();
|
||
setupTime();
|
||
displayMode = 0;
|
||
lastSwitch = millis();
|
||
lastColonBlink = millis();
|
||
}
|
||
|
||
void advanceDisplayMode() {
|
||
int oldMode = displayMode;
|
||
if (displayMode == 0) {
|
||
displayMode = 1; // clock -> weather
|
||
} else if (displayMode == 1 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) {
|
||
displayMode = 2; // weather -> description
|
||
} else {
|
||
displayMode = 0; // description (or weather if no desc) -> clock
|
||
}
|
||
lastSwitch = millis();
|
||
// Serial print for debugging
|
||
const char *modeName = displayMode == 0 ? "CLOCK" : displayMode == 1 ? "WEATHER"
|
||
: "DESCRIPTION";
|
||
Serial.printf("[LOOP] Switching to display mode: %s\n", modeName);
|
||
}
|
||
|
||
void loop() {
|
||
if (isAPMode) {
|
||
dnsServer.processNextRequest();
|
||
}
|
||
|
||
// AP Mode animation
|
||
static unsigned long apAnimTimer = 0;
|
||
static int apAnimFrame = 0;
|
||
if (isAPMode) {
|
||
unsigned long now = millis();
|
||
if (now - apAnimTimer > 750) {
|
||
apAnimTimer = now;
|
||
apAnimFrame++;
|
||
}
|
||
P.setTextAlignment(PA_CENTER);
|
||
switch (apAnimFrame % 3) {
|
||
case 0: P.print(F("= ©")); break;
|
||
case 1: P.print(F("= ª")); break;
|
||
case 2: P.print(F("= «")); break;
|
||
}
|
||
yield();
|
||
return;
|
||
}
|
||
|
||
// Dimming
|
||
time_t now = time(nullptr);
|
||
struct tm timeinfo;
|
||
localtime_r(&now, &timeinfo);
|
||
int curHour = timeinfo.tm_hour;
|
||
int curMinute = timeinfo.tm_min;
|
||
int curTotal = curHour * 60 + curMinute;
|
||
int startTotal = dimStartHour * 60 + dimStartMinute;
|
||
int endTotal = dimEndHour * 60 + dimEndMinute;
|
||
bool isDimming = false;
|
||
|
||
if (dimmingEnabled) {
|
||
if (startTotal < endTotal) {
|
||
isDimming = (curTotal >= startTotal && curTotal < endTotal);
|
||
} else {
|
||
isDimming = (curTotal >= startTotal || curTotal < endTotal);
|
||
}
|
||
if (isDimming) {
|
||
P.setIntensity(dimBrightness);
|
||
} else {
|
||
P.setIntensity(brightness);
|
||
}
|
||
} else {
|
||
P.setIntensity(brightness);
|
||
}
|
||
|
||
// Show IP after WiFi connect
|
||
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);
|
||
displayMode = 0;
|
||
lastSwitch = millis();
|
||
}
|
||
}
|
||
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
|
||
|
||
switch (ntpState) {
|
||
case NTP_IDLE: break;
|
||
case NTP_SYNCING:
|
||
{
|
||
time_t now = time(nullptr);
|
||
if (now > 1000) {
|
||
Serial.println(F("\n[TIME] NTP sync successful."));
|
||
ntpSyncSuccessful = true;
|
||
ntpState = NTP_SUCCESS;
|
||
} else if (millis() - ntpStartTime > ntpTimeout || ntpRetryCount > maxNtpRetries) {
|
||
Serial.println(F("\n[TIME] NTP sync failed."));
|
||
ntpSyncSuccessful = false;
|
||
ntpState = NTP_FAILED;
|
||
} else {
|
||
if (millis() - ntpStartTime > ((unsigned long)ntpRetryCount * 1000)) {
|
||
Serial.print(F("."));
|
||
ntpRetryCount++;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case NTP_SUCCESS:
|
||
if (!tzSetAfterSync) {
|
||
const char *posixTz = ianaToPosix(timeZone);
|
||
setenv("TZ", posixTz, 1);
|
||
tzset();
|
||
tzSetAfterSync = true;
|
||
}
|
||
ntpAnimTimer = 0;
|
||
ntpAnimFrame = 0;
|
||
break;
|
||
case NTP_FAILED:
|
||
ntpAnimTimer = 0;
|
||
ntpAnimFrame = 0;
|
||
break;
|
||
}
|
||
|
||
// --- MODIFIED WEATHER FETCHING LOGIC ---
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
// Check if an immediate fetch is requested OR if the regular interval has passed
|
||
if (!weatherFetchInitiated || shouldFetchWeatherNow || (millis() - lastFetch > fetchInterval)) {
|
||
if (shouldFetchWeatherNow) {
|
||
Serial.println(F("[LOOP] Immediate weather fetch requested by web server."));
|
||
shouldFetchWeatherNow = false; // Reset the flag after handling
|
||
} else if (!weatherFetchInitiated) {
|
||
Serial.println(F("[LOOP] Initial weather fetch."));
|
||
} else {
|
||
Serial.println(F("[LOOP] Regular interval weather fetch."));
|
||
}
|
||
|
||
weatherFetchInitiated = true;
|
||
weatherFetched = false; // Mark as not yet fetched
|
||
fetchWeather();
|
||
lastFetch = millis();
|
||
}
|
||
} else {
|
||
weatherFetchInitiated = false;
|
||
// It's good practice to reset the flag if WiFi disconnects to avoid stale requests
|
||
shouldFetchWeatherNow = false;
|
||
}
|
||
// --- END MODIFIED WEATHER FETCHING LOGIC ---
|
||
|
||
const char *const *daysOfTheWeek = getDaysOfWeek(language);
|
||
const char *daySymbol = daysOfTheWeek[timeinfo.tm_wday];
|
||
|
||
char timeStr[9];
|
||
if (twelveHourToggle) {
|
||
int hour12 = timeinfo.tm_hour % 12;
|
||
if (hour12 == 0) hour12 = 12;
|
||
sprintf(timeStr, " %d:%02d", hour12, timeinfo.tm_min);
|
||
} else {
|
||
sprintf(timeStr, " %02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
|
||
}
|
||
|
||
char timeSpacedStr[20];
|
||
int j = 0;
|
||
for (int i = 0; timeStr[i] != '\0'; i++) {
|
||
timeSpacedStr[j++] = timeStr[i];
|
||
if (timeStr[i + 1] != '\0') {
|
||
timeSpacedStr[j++] = ' ';
|
||
}
|
||
}
|
||
timeSpacedStr[j] = '\0';
|
||
|
||
String formattedTime;
|
||
if (showDayOfWeek) {
|
||
formattedTime = String(daySymbol) + " " + String(timeSpacedStr);
|
||
} else {
|
||
formattedTime = String(timeSpacedStr);
|
||
}
|
||
|
||
// --- Weather Description Mode handling ---
|
||
static unsigned long descStartTime = 0;
|
||
static bool descScrolling = false;
|
||
static unsigned long descScrollEndTime = 0; // for post-scroll delay
|
||
const unsigned long descriptionDuration = 3000; // 3s for short text
|
||
const unsigned long descriptionScrollPause = 300; // 300ms pause after scroll
|
||
|
||
// 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();
|
||
}
|
||
|
||
// --- WEATHER DESCRIPTION Display Mode ---
|
||
if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) {
|
||
String desc = weatherDescription;
|
||
desc.toUpperCase();
|
||
|
||
if (desc.length() > 8) {
|
||
if (!descScrolling) {
|
||
P.displayClear();
|
||
textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay);
|
||
P.displayScroll(desc.c_str(), PA_CENTER, actualScrollDirection, 100);
|
||
descScrolling = true;
|
||
descScrollEndTime = 0; // reset end time at start
|
||
}
|
||
if (P.displayAnimate()) {
|
||
if (descScrollEndTime == 0) {
|
||
descScrollEndTime = millis(); // mark the time when scroll finishes
|
||
}
|
||
// wait small pause after scroll stops
|
||
if (millis() - descScrollEndTime > descriptionScrollPause) {
|
||
descScrolling = false;
|
||
descScrollEndTime = 0;
|
||
advanceDisplayMode();
|
||
}
|
||
} else {
|
||
descScrollEndTime = 0; // reset if not finished
|
||
}
|
||
yield();
|
||
return;
|
||
} else {
|
||
if (descStartTime == 0) {
|
||
P.setTextAlignment(PA_CENTER);
|
||
P.setCharSpacing(1);
|
||
P.print(desc.c_str());
|
||
descStartTime = millis();
|
||
}
|
||
if (millis() - descStartTime > descriptionDuration) {
|
||
descStartTime = 0;
|
||
advanceDisplayMode();
|
||
}
|
||
yield();
|
||
return;
|
||
}
|
||
}
|
||
|
||
static bool weatherWasAvailable = false;
|
||
// --- CLOCK Display Mode ---
|
||
if (displayMode == 0) {
|
||
P.setCharSpacing(0);
|
||
if (ntpState == NTP_SYNCING) {
|
||
if (millis() - ntpAnimTimer > 750) {
|
||
ntpAnimTimer = millis();
|
||
switch (ntpAnimFrame % 3) {
|
||
case 0: P.print(F("S Y N C ®")); break;
|
||
case 1: P.print(F("S Y N C ¯")); break;
|
||
case 2: P.print(F("S Y N C °")); break;
|
||
}
|
||
ntpAnimFrame++;
|
||
}
|
||
} else if (!ntpSyncSuccessful) {
|
||
P.setTextAlignment(PA_CENTER);
|
||
P.print(F("?/"));
|
||
} else {
|
||
String timeString = formattedTime;
|
||
if (!colonVisible) timeString.replace(":", " ");
|
||
P.print(timeString);
|
||
}
|
||
yield();
|
||
return;
|
||
}
|
||
|
||
// --- WEATHER Display Mode ---
|
||
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;
|
||
}
|
||
|
||
yield();
|
||
} |