diff --git a/ESPTimeCast.ino b/ESPTimeCast.ino index adab4cf..935d589 100644 --- a/ESPTimeCast.ino +++ b/ESPTimeCast.ino @@ -33,6 +33,13 @@ char timeZone[64] = ""; unsigned long clockDuration = 10000; unsigned long weatherDuration = 5000; +// ADVANCED SETTINGS +int brightness = 7; +bool flipDisplay = false; +bool twelveHourToggle = false; // <-- NEW: 12h/24h clock toggle +char ntpServer1[64] = "pool.ntp.org"; +char ntpServer2[64] = "time.nist.gov"; + WiFiClient client; const byte DNS_PORT = 53; DNSServer dnsServer; @@ -78,6 +85,11 @@ void printConfigToSerial() { 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("Brightness: ")); Serial.println(brightness); + Serial.print(F("FlipDisplay: ")); Serial.println(flipDisplay ? "Yes" : "No"); + Serial.print(F("12h Clock: ")); Serial.println(twelveHourToggle ? "Yes" : "No"); // <-- NEW + Serial.print(F("NTP Server 1: ")); Serial.println(ntpServer1); + Serial.print(F("NTP Server 2: ")); Serial.println(ntpServer2); Serial.println(F("========================================")); Serial.println(); } @@ -101,6 +113,11 @@ void loadConfig() { doc[F("clockDuration")] = 8000; doc[F("weatherDuration")] = 5000; doc[F("timeZone")] = "Asia/Tokyo"; + doc[F("brightness")] = brightness; + doc[F("flipDisplay")] = flipDisplay; + doc[F("twelveHourToggle")] = twelveHourToggle; // <-- NEW + doc[F("ntpServer1")] = ntpServer1; + doc[F("ntpServer2")] = ntpServer2; File f = LittleFS.open("/config.json", "w"); if (f) { serializeJsonPretty(doc, f); @@ -139,6 +156,11 @@ void loadConfig() { if (doc.containsKey("clockDuration")) clockDuration = doc["clockDuration"]; if (doc.containsKey("weatherDuration")) weatherDuration = doc["weatherDuration"]; if (doc.containsKey("timeZone")) strlcpy(timeZone, doc["timeZone"], sizeof(timeZone)); + if (doc.containsKey("brightness")) brightness = doc["brightness"]; + if (doc.containsKey("flipDisplay")) flipDisplay = doc["flipDisplay"]; + if (doc.containsKey("twelveHourToggle")) twelveHourToggle = doc["twelveHourToggle"]; // <-- NEW + if (doc.containsKey("ntpServer1")) strlcpy(ntpServer1, doc["ntpServer1"], sizeof(ntpServer1)); + if (doc.containsKey("ntpServer2")) strlcpy(ntpServer2, doc["ntpServer2"], sizeof(ntpServer2)); if (strcmp(weatherUnits, "imperial") == 0) tempSymbol = 'F'; else if (strcmp(weatherUnits, "standard") == 0) @@ -193,8 +215,7 @@ void connectWiFi() { void setupTime() { sntp_stop(); Serial.println(F("[TIME] Starting NTP sync...")); - configTime(0, 0, "pool.ntp.org", "time.nist.gov"); // Start NTP - // NOW set time zone after starting configTime + configTime(0, 0, ntpServer1, ntpServer2); // Use custom NTP servers setenv("TZ", ianaToPosix(timeZone), 1); tzset(); ntpState = NTP_SYNCING; @@ -231,12 +252,17 @@ void setupWebServer() { serializeJson(doc, response); request->send(200, "application/json", response); }); - server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request){ + server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request){ Serial.println(F("[WEBSERVER] Request: /save")); DynamicJsonDocument doc(2048); for (int i = 0; i < request->params(); i++) { const AsyncWebParameter* p = request->getParam(i); - doc[p->name()] = p->value(); + String n = p->name(); + String v = p->value(); + if (n == "brightness") 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"); // <-- NEW + else doc[n] = v; } if (LittleFS.exists("/config.json")) { LittleFS.rename("/config.json", "/config.bak"); @@ -274,155 +300,62 @@ void setupWebServer() { request->send(200, "application/json", response); Serial.println(F("[WEBSERVER] Rebooting...")); request->onDisconnect([]() { - Serial.println(F("[WEBSERVER] Rebooting...")); - // delay(2500); // optional, can be reduced or omitted - ESP.restart(); - }); - -}); - -// server.on("/save", HTTP_POST, [](AsyncWebServerRequest *request){ -// Serial.println(F("[WEBSERVER] Request: /save")); -// DynamicJsonDocument doc(2048); -// for (int i = 0; i < request->params(); i++) { -// const AsyncWebParameter* p = request->getParam(i); -// doc[p->name()] = p->value(); -// } - -// // Simulate "Failed to Write Config" error: -// Serial.println(F("[WEBSERVER] Simulating LittleFS read-only error")); -// LittleFS.end(); // Unmount the filesystem (make it read-only) - -// if (LittleFS.exists("/config.json")) { -// LittleFS.rename("/config.json", "/config.bak"); -// } -// File f = LittleFS.open("/config.json", "w"); -// if (!f) { -// Serial.println(F("[WEBSERVER] Failed to open /config.json for writing")); -// DynamicJsonDocument errorDoc(256); -// errorDoc[F("error")] = "Failed to write config"; -// String response; -// serializeJson(errorDoc, response); -// request->send(500, "application/json", response); -// return; -// } -// serializeJson(doc, f); -// f.close(); - -// // Remount the filesystem (allow writes again for future operations) -// Serial.println(F("[WEBSERVER] Remounting LittleFS")); -// if (!LittleFS.begin()) { -// Serial.println(F("[WEBSERVER] LittleFS mount failed after simulating error!")); -// // Handle the error appropriately (e.g., send an error response) -// } - -// File verify = LittleFS.open("/config.json", "r"); -// DynamicJsonDocument test(2048); -// DeserializationError err = deserializeJson(test, verify); -// verify.close(); -// if (err) { -// Serial.print(F("[WEBSERVER] Config corrupted after save: ")); -// Serial.println(err.f_str()); -// DynamicJsonDocument errorDoc(256); -// errorDoc[F("error")] = "Config corrupted. Reboot cancelled."; -// String response; -// serializeJson(errorDoc, response); -// request->send(500, "application/json", response); -// return; -// } - -// DynamicJsonDocument okDoc(128); -// okDoc[F("message")] = "Saved successfully. Rebooting..."; -// String response; -// serializeJson(okDoc, response); -// request->send(200, "application/json", response); -// Serial.println(F("[WEBSERVER] Rebooting...")); -// ESP.restart(); -// }); - - - // server.on("/restore", HTTP_POST, [](AsyncWebServerRequest *request){ - // Serial.println(F("[WEBSERVER] Request: /restore")); - // if (LittleFS.exists("/config.bak")) { - // LittleFS.remove("/config.json"); - // if (LittleFS.rename("/config.bak", "/config.json")) { - // DynamicJsonDocument okDoc(128); - // okDoc[F("message")] = "Backup restored."; - // String response; - // serializeJson(okDoc, response); - // request->send(200, "application/json", response); - // } else { - // Serial.println(F("[WEBSERVER] Failed to rename backup")); - // DynamicJsonDocument errorDoc(128); - // errorDoc[F("error")] = "Failed to restore backup."; - // String response; - // serializeJson(errorDoc, response); - // request->send(500, "application/json", response); - // return; - // } - // } 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("/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; - } - // Copy contents - 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...")); + Serial.println(F("[WEBSERVER] Rebooting...")); 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("/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; + } + // Copy contents + 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); + } + }); - // Add the /ap_status endpoint here: server.on("/ap_status", HTTP_GET, [](AsyncWebServerRequest *request){ Serial.print(F("[WEBSERVER] Request: /ap_status. isAPMode = ")); Serial.println(isAPMode); @@ -518,7 +451,6 @@ void fetchWeather() { } break; // All done! } - // If not yet body, keep looping for the header lines yield(); delay(1); } @@ -569,10 +501,11 @@ void setup() { Serial.println(F("[SETUP] Starting setup...")); P.begin(); P.setFont(mFactory); // Custom font - P.setIntensity(8); + loadConfig(); // Load config before setting intensity & flip + 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")); - loadConfig(); - Serial.println(F("[SETUP] Config loaded")); connectWiFi(); Serial.println(F("[SETUP] Wifi connected")); setupWebServer(); @@ -673,9 +606,9 @@ void loop() { fetchWeather(); lastFetch = millis(); } -} else { + } else { weatherFetchInitiated = false; -} + } // Time display logic time_t now = time(nullptr); @@ -684,8 +617,15 @@ void loop() { int dayOfWeek = timeinfo.tm_wday; char* daySymbol = daysOfTheWeek[dayOfWeek]; - char timeStr[6]; + + char timeStr[9]; // enough for "12:34 AM" +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); +} String formattedTime = String(daySymbol) + " " + String(timeStr); unsigned long displayDuration = (displayMode == 0) ? clockDuration : weatherDuration; @@ -739,8 +679,7 @@ void loop() { const unsigned long displayUpdateInterval = 50; if (millis() - lastDisplayUpdate >= displayUpdateInterval) { lastDisplayUpdate = millis(); -} + } yield(); -} - +} \ No newline at end of file diff --git a/assets/webui3.png b/assets/webui3.png new file mode 100644 index 0000000..26849ed Binary files /dev/null and b/assets/webui3.png differ diff --git a/data/config.json b/data/config.json index 54174b5..7df76f1 100644 --- a/data/config.json +++ b/data/config.json @@ -4,8 +4,13 @@ "openWeatherApiKey": "ADD-YOUR-API-KEY-32-CHARACTERS", "openWeatherCity": "", "openWeatherCountry": "", - "clockDuration": "10000", - "weatherDuration": "5000", + "clockDuration": 10000, + "weatherDuration": 5000, "timeZone": "", - "weatherUnits": "metric" -} + "weatherUnits": "metric", + "brightness": 10, + "flipDisplay": false, + "ntpServer1": "pool.ntp.org", + "ntpServer2": "time.nist.gov", + "twelveHourToggle": false +} \ No newline at end of file diff --git a/data/index.html b/data/index.html index 603daea..71e7070 100644 --- a/data/index.html +++ b/data/index.html @@ -5,6 +5,11 @@ ESPTimeCast Settings @@ -209,103 +292,102 @@ body.loaded {
Consult the OpenWeatherMap - documendation for info about getting your API key, city, and country code. -
+ documentation for info about getting your API key, city, and country code. +

Clock Settings

- - - +
@@ -319,7 +401,47 @@ body.loaded {
-


+ + + + @@ -333,50 +455,20 @@ body.loaded { let isSaving = false; let isAPMode = false; -function ensureReloadButton(options = {}) { - let modalContent = document.getElementById('savingModalContent'); - if (!modalContent) return; - let btn = document.getElementById('reloadButton'); - if (!btn) { - btn = document.createElement('button'); - btn.id = 'reloadButton'; - btn.className = 'primary-button'; - btn.style.display = 'inline-block'; - btn.style.margin = '1rem 0.5rem 0 0'; - modalContent.appendChild(btn); - } - btn.textContent = options.text || "Reload Page"; - btn.onclick = options.onClick || (() => location.reload()); - btn.style.display = 'inline-block'; - return btn; -} +// Set initial value display for brightness +document.addEventListener('DOMContentLoaded', function() { + brightnessValue.textContent = brightnessSlider.value; +}); -function ensureRestoreButton(options = {}) { - let modalContent = document.getElementById('savingModalContent'); - if (!modalContent) return; - let btn = document.getElementById('restoreButton'); - if (!btn) { - btn = document.createElement('button'); - btn.id = 'restoreButton'; - btn.className = 'primary-button'; - btn.style.display = 'inline-block'; - btn.style.margin = '1rem 0 0 0.5rem'; - modalContent.appendChild(btn); - } - btn.textContent = options.text || "Restore Backup"; - btn.onclick = options.onClick || restoreBackupConfig; - btn.style.display = 'inline-block'; - return btn; -} +// Show/hide password toggle +document.addEventListener("DOMContentLoaded", function () { + const passwordInput = document.getElementById("password"); + const toggleCheckbox = document.getElementById("togglePassword"); -function removeReloadButton() { - let btn = document.getElementById('reloadButton'); - if (btn && btn.parentNode) btn.parentNode.removeChild(btn); -} -function removeRestoreButton() { - let btn = document.getElementById('restoreButton'); - if (btn && btn.parentNode) btn.parentNode.removeChild(btn); -} + toggleCheckbox.addEventListener("change", function () { + passwordInput.type = this.checked ? "text" : "password"; + }); +}); window.onbeforeunload = function () { if (isSaving) { @@ -401,7 +493,13 @@ window.onload = function () { document.getElementById('weatherUnits').value = data.weatherUnits || 'metric'; document.getElementById('clockDuration').value = (data.clockDuration || 10000) / 1000; document.getElementById('weatherDuration').value = (data.weatherDuration || 5000) / 1000; - + // Advanced: + document.getElementById('brightnessSlider').value = typeof data.brightness !== "undefined" ? data.brightness : 10; + document.getElementById('brightnessValue').textContent = document.getElementById('brightnessSlider').value; + document.getElementById('flipDisplay').checked = !!data.flipDisplay; + document.getElementById('ntpServer1').value = data.ntpServer1 || ""; + document.getElementById('ntpServer2').value = data.ntpServer2 || ""; + document.getElementById('twelveHourToggle').checked = !!data.twelveHourToggle; // Auto-detect browser's timezone if not set in config if (!data.timeZone) { try { @@ -442,8 +540,6 @@ window.onload = function () { }); document.querySelector('html').style.height = 'unset'; document.body.classList.add('loaded'); - - }; async function submitConfig(event) { @@ -458,6 +554,11 @@ async function submitConfig(event) { formData.set('clockDuration', clockDuration); formData.set('weatherDuration', weatherDuration); + // Advanced: ensure correct values are set for advanced fields + formData.set('brightness', document.getElementById('brightnessSlider').value); + formData.set('flipDisplay', document.getElementById('flipDisplay').checked ? 'on' : ''); + formData.set('twelveHourToggle', document.getElementById('twelveHourToggle').checked ? 'on' : ''); + const params = new URLSearchParams(); for (const pair of formData.entries()) { params.append(pair[0], pair[1]); @@ -579,6 +680,51 @@ function updateSavingModal(message, showSpinner = false) { } } +function ensureReloadButton(options = {}) { + let modalContent = document.getElementById('savingModalContent'); + if (!modalContent) return; + let btn = document.getElementById('reloadButton'); + if (!btn) { + btn = document.createElement('button'); + btn.id = 'reloadButton'; + btn.className = 'primary-button'; + btn.style.display = 'inline-block'; + btn.style.margin = '1rem 0.5rem 0 0'; + modalContent.appendChild(btn); + } + btn.textContent = options.text || "Reload Page"; + btn.onclick = options.onClick || (() => location.reload()); + btn.style.display = 'inline-block'; + return btn; +} + +function ensureRestoreButton(options = {}) { + let modalContent = document.getElementById('savingModalContent'); + if (!modalContent) return; + let btn = document.getElementById('restoreButton'); + if (!btn) { + btn = document.createElement('button'); + btn.id = 'restoreButton'; + btn.className = 'primary-button'; + btn.style.display = 'inline-block'; + btn.style.margin = '1rem 0 0 0.5rem'; + modalContent.appendChild(btn); + } + btn.textContent = options.text || "Restore Backup"; + btn.onclick = options.onClick || restoreBackupConfig; + btn.style.display = 'inline-block'; + return btn; +} + +function removeReloadButton() { + let btn = document.getElementById('reloadButton'); + if (btn && btn.parentNode) btn.parentNode.removeChild(btn); +} +function removeRestoreButton() { + let btn = document.getElementById('restoreButton'); + if (btn && btn.parentNode) btn.parentNode.removeChild(btn); +} + function restoreBackupConfig() { showSavingModal("Restoring backup..."); removeReloadButton(); @@ -606,16 +752,6 @@ function restoreBackupConfig() { }); } -// Show/hide password toggle -document.addEventListener("DOMContentLoaded", function () { - const passwordInput = document.getElementById("password"); - const toggleCheckbox = document.getElementById("togglePassword"); - - toggleCheckbox.addEventListener("change", function () { - passwordInput.type = this.checked ? "text" : "password"; - }); -}); - function hideSavingModal() { const modal = document.getElementById('savingModal'); if (modal) { @@ -623,6 +759,31 @@ function hideSavingModal() { document.body.classList.remove('modal-open'); } } + +const toggle = document.querySelector('.collapsible-toggle'); + const content = document.querySelector('.collapsible-content'); + toggle.addEventListener('click', function() { + const isOpen = toggle.classList.toggle('open'); + toggle.setAttribute('aria-expanded', isOpen); + content.setAttribute('aria-hidden', !isOpen); + if(isOpen) { + content.style.height = content.scrollHeight + 'px'; + content.addEventListener('transitionend', function handler() { + content.style.height = 'auto'; + content.removeEventListener('transitionend', handler); + }); + } else { + content.style.height = content.scrollHeight + 'px'; + // Force reflow to make sure the browser notices the height before transitioning to 0 + void content.offsetHeight; + content.style.height = '0px'; + } + }); + // Optional: If open on load, set height to auto + if(toggle.classList.contains('open')) { + content.style.height = 'auto'; + } + - + \ No newline at end of file