Added Advance setting:

Added Advance setting:
Custom Primary/Secondary NTP server input
24/12h clock mode toggle (24-hour default)
Flip display (180 degrees)
Adjustable display brightness
This commit is contained in:
mfactory-osaka
2025-06-12 17:38:35 +09:00
parent 8a9caa52c5
commit e3a27974db
4 changed files with 433 additions and 328 deletions

View File

@@ -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();
}
}

BIN
assets/webui3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

View File

@@ -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
}

View File

@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ESPTimeCast Settings</title>
<style>
:root{
--accent-color: #0075ff;
}
* { box-sizing: border-box; }
html{
background: radial-gradient(ellipse at 70% 0%, #2b425a 0%, #171e23 100%);
@@ -17,18 +22,18 @@
color: #FFFFFF;
background-repeat: no-repeat, repeat, repeat;
opacity: 0;
transition: opacity 0.6s cubic-bezier(.4,0,.2,1);
visibility: 0;
transition: opacity 0.6s cubic-bezier(.4,0,.2,1);
visibility: 0;
}
body.loaded {
visibility: visible;
opacity: 1;
}
body.loaded {
visibility: visible;
opacity: 1;
}
body.modal-open {
overflow: hidden;
}
}
h1 {
text-align: center;
font-size: 1.5rem;
@@ -62,7 +67,7 @@ body.loaded {
}
label {
font-size: 0.9rem;
margin-bottom: 0.25rem;
margin-bottom: 0.25rem;
display: block;
margin-top: 0.75rem;
}
@@ -85,6 +90,24 @@ body.loaded {
input[type="submit"]:hover {
background-color: #005ecb;
}
input:-webkit-autofill,
input:-webkit-autofill:focus,
input:-webkit-autofill:hover {
background: rgba(225,245,255,0.07) !important;
color: #fff !important;
-webkit-box-shadow: 0 0 0 1000px rgba(225,245,255,0.07) inset !important;
box-shadow: 0 0 0 1000px rgba(225,245,255,0.07) inset !important;
-webkit-text-fill-color: #fff !important;
transition: background 9999s ease-in-out 0s;
}
input::placeholder,
textarea::placeholder {
color: #fff; /* Example: light blue */
opacity: 1; /* Make sure it's not semi-transparent */
}
.form-row {
display: flex;
flex-direction: column;
@@ -155,20 +178,80 @@ body.loaded {
margin-top: 0.25rem;
}
select option {
color: black;
}
select option {
color: black;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (min-width: 320px) {
.form-row.two-col {
flex-direction: row;
gap: 1rem; }
.form-row.two-col {
flex-direction: row;
gap: 1rem; }
}
/* Make sure .details-content is visible only when open */
.animated-details[open] .details-content {
/* JS will animate the height */
}
/* Toggle Switch Styling for Flip Display */
.toggle-switch { position: relative; display: inline-block; width: 48px; height: 24px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc; transition: .4s; border-radius: 24px;
}
.toggle-slider:before {
position: absolute; content: ""; height: 20px; width: 20px; left: 2px; bottom: 2px;
background-color: white; transition: .4s; border-radius: 50%;
}
input:checked + .toggle-slider { background-color: var(--accent-color); }
input:checked + .toggle-slider:before { transform: translateX(24px); }
.accent{
accent-color: var(--accent-color);
}
.collapsible-toggle {
display: flex;
align-items: center;
cursor: pointer;
font-size: 1.1rem;
font-weight: bold;
background: none;
border: none;
color: white;
padding: 0;
margin: 0;
outline: none;
gap: 0.5em;
user-select: none;
margin-top: 2.5rem;
}
.collapsible-toggle .icon-area {
transition: transform 0.3s cubic-bezier(.4,0,.2,1);
display: flex;
}
.collapsible-toggle.open .icon-area {
transform: rotate(90deg);
}
.collapsible-content {
overflow: hidden;
height: 0;
transition: height 0.3s cubic-bezier(.4,0,.2,1);
color: #fff;
margin-bottom: 2rem;
}
.collapsible-content-inner {
padding: 1em 0;
}
</style>
</head>
<body>
@@ -209,103 +292,102 @@ body.loaded {
</select>
<div class="small">
Consult the <a href="https://openweathermap.org/price" target="_blank" rel="noopener">OpenWeatherMap</a>
documendation for info about getting your API key, city, and country code.
</div>
documentation for info about getting your API key, city, and country code.
</div>
<h2>Clock Settings</h2>
<label for="timeZone">Time Zone</label>
<select id="timeZone" name="timeZone" required>
<option value="" disabled selected>Select your time zone</option>
<option value="Africa/Algiers">Africa/Algiers</option>
<option value="Africa/Cairo">Africa/Cairo</option>
<option value="Africa/Casablanca">Africa/Casablanca</option>
<option value="Africa/Johannesburg">Africa/Johannesburg</option>
<option value="Africa/Lagos">Africa/Lagos</option>
<option value="Africa/Nairobi">Africa/Nairobi</option>
<option value="America/Anchorage">America/Anchorage</option>
<option value="America/Argentina/Buenos_Aires">America/Argentina/Buenos_Aires</option>
<option value="America/Bogota">America/Bogota</option>
<option value="America/Caracas">America/Caracas</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Lima">America/Lima</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="America/Mexico_City">America/Mexico_City</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Phoenix">America/Phoenix</option>
<option value="America/Santiago">America/Santiago</option>
<option value="America/Sao_Paulo">America/Sao_Paulo</option>
<option value="America/Toronto">America/Toronto</option>
<option value="America/Vancouver">America/Vancouver</option>
<option value="Asia/Almaty">Asia/Almaty</option>
<option value="Asia/Amman">Asia/Amman</option>
<option value="Asia/Baghdad">Asia/Baghdad</option>
<option value="Asia/Baku">Asia/Baku</option>
<option value="Asia/Bangkok">Asia/Bangkok</option>
<option value="Asia/Beirut">Asia/Beirut</option>
<option value="Asia/Dhaka">Asia/Dhaka</option>
<option value="Asia/Dubai">Asia/Dubai</option>
<option value="Asia/Ho_Chi_Minh">Asia/Ho_Chi_Minh</option>
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
<option value="Asia/Jakarta">Asia/Jakarta</option>
<option value="Asia/Jerusalem">Asia/Jerusalem</option>
<option value="Asia/Kabul">Asia/Kabul</option>
<option value="Asia/Karachi">Asia/Karachi</option>
<option value="Asia/Kathmandu">Asia/Kathmandu</option>
<option value="Asia/Kolkata">Asia/Kolkata</option>
<option value="Asia/Kuala_Lumpur">Asia/Kuala_Lumpur</option>
<option value="Asia/Kuwait">Asia/Kuwait</option>
<option value="Asia/Manila">Asia/Manila</option>
<option value="Asia/Riyadh">Asia/Riyadh</option>
<option value="Asia/Seoul">Asia/Seoul</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Singapore">Asia/Singapore</option>
<option value="Asia/Taipei">Asia/Taipei</option>
<option value="Asia/Tashkent">Asia/Tashkent</option>
<option value="Asia/Tehran">Asia/Tehran</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Yangon">Asia/Yangon</option>
<option value="Australia/Adelaide">Australia/Adelaide</option>
<option value="Australia/Brisbane">Australia/Brisbane</option>
<option value="Australia/Melbourne">Australia/Melbourne</option>
<option value="Australia/Perth">Australia/Perth</option>
<option value="Australia/Sydney">Australia/Sydney</option>
<option value="Europe/Amsterdam">Europe/Amsterdam</option>
<option value="Europe/Athens">Europe/Athens</option>
<option value="Europe/Belgrade">Europe/Belgrade</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Brussels">Europe/Brussels</option>
<option value="Europe/Bucharest">Europe/Bucharest</option>
<option value="Europe/Budapest">Europe/Budapest</option>
<option value="Europe/Copenhagen">Europe/Copenhagen</option>
<option value="Europe/Dublin">Europe/Dublin</option>
<option value="Europe/Helsinki">Europe/Helsinki</option>
<option value="Europe/Istanbul">Europe/Istanbul</option>
<option value="Europe/Kiev">Europe/Kiev</option>
<option value="Europe/Lisbon">Europe/Lisbon</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Madrid">Europe/Madrid</option>
<option value="Europe/Moscow">Europe/Moscow</option>
<option value="Europe/Oslo">Europe/Oslo</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Europe/Prague">Europe/Prague</option>
<option value="Europe/Riga">Europe/Riga</option>
<option value="Europe/Rome">Europe/Rome</option>
<option value="Europe/Sofia">Europe/Sofia</option>
<option value="Europe/Stockholm">Europe/Stockholm</option>
<option value="Europe/Tallinn">Europe/Tallinn</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Vilnius">Europe/Vilnius</option>
<option value="Europe/Warsaw">Europe/Warsaw</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="Pacific/Auckland">Pacific/Auckland</option>
<option value="Pacific/Fiji">Pacific/Fiji</option>
<option value="Pacific/Guam">Pacific/Guam</option>
<option value="Pacific/Honolulu">Pacific/Honolulu</option>
<option value="Etc/UTC">Etc/UTC</option>
</select>
<select id="timeZone" name="timeZone" required>
<option value="" disabled selected>Select your time zone</option>
<!-- ... (timezone options unchanged) ... -->
<option value="Africa/Algiers">Africa/Algiers</option>
<option value="Africa/Cairo">Africa/Cairo</option>
<option value="Africa/Casablanca">Africa/Casablanca</option>
<option value="Africa/Johannesburg">Africa/Johannesburg</option>
<option value="Africa/Lagos">Africa/Lagos</option>
<option value="Africa/Nairobi">Africa/Nairobi</option>
<option value="America/Anchorage">America/Anchorage</option>
<option value="America/Argentina/Buenos_Aires">America/Argentina/Buenos_Aires</option>
<option value="America/Bogota">America/Bogota</option>
<option value="America/Caracas">America/Caracas</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Lima">America/Lima</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="America/Mexico_City">America/Mexico_City</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Phoenix">America/Phoenix</option>
<option value="America/Santiago">America/Santiago</option>
<option value="America/Sao_Paulo">America/Sao_Paulo</option>
<option value="America/Toronto">America/Toronto</option>
<option value="America/Vancouver">America/Vancouver</option>
<option value="Asia/Almaty">Asia/Almaty</option>
<option value="Asia/Amman">Asia/Amman</option>
<option value="Asia/Baghdad">Asia/Baghdad</option>
<option value="Asia/Baku">Asia/Baku</option>
<option value="Asia/Bangkok">Asia/Bangkok</option>
<option value="Asia/Beirut">Asia/Beirut</option>
<option value="Asia/Dhaka">Asia/Dhaka</option>
<option value="Asia/Dubai">Asia/Dubai</option>
<option value="Asia/Ho_Chi_Minh">Asia/Ho_Chi_Minh</option>
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
<option value="Asia/Jakarta">Asia/Jakarta</option>
<option value="Asia/Jerusalem">Asia/Jerusalem</option>
<option value="Asia/Kabul">Asia/Kabul</option>
<option value="Asia/Karachi">Asia/Karachi</option>
<option value="Asia/Kathmandu">Asia/Kathmandu</option>
<option value="Asia/Kolkata">Asia/Kolkata</option>
<option value="Asia/Kuala_Lumpur">Asia/Kuala_Lumpur</option>
<option value="Asia/Kuwait">Asia/Kuwait</option>
<option value="Asia/Manila">Asia/Manila</option>
<option value="Asia/Riyadh">Asia/Riyadh</option>
<option value="Asia/Seoul">Asia/Seoul</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Singapore">Asia/Singapore</option>
<option value="Asia/Taipei">Asia/Taipei</option>
<option value="Asia/Tashkent">Asia/Tashkent</option>
<option value="Asia/Tehran">Asia/Tehran</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Yangon">Asia/Yangon</option>
<option value="Australia/Adelaide">Australia/Adelaide</option>
<option value="Australia/Brisbane">Australia/Brisbane</option>
<option value="Australia/Melbourne">Australia/Melbourne</option>
<option value="Australia/Perth">Australia/Perth</option>
<option value="Australia/Sydney">Australia/Sydney</option>
<option value="Europe/Amsterdam">Europe/Amsterdam</option>
<option value="Europe/Athens">Europe/Athens</option>
<option value="Europe/Belgrade">Europe/Belgrade</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Brussels">Europe/Brussels</option>
<option value="Europe/Bucharest">Europe/Bucharest</option>
<option value="Europe/Budapest">Europe/Budapest</option>
<option value="Europe/Copenhagen">Europe/Copenhagen</option>
<option value="Europe/Dublin">Europe/Dublin</option>
<option value="Europe/Helsinki">Europe/Helsinki</option>
<option value="Europe/Istanbul">Europe/Istanbul</option>
<option value="Europe/Kiev">Europe/Kiev</option>
<option value="Europe/Lisbon">Europe/Lisbon</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Madrid">Europe/Madrid</option>
<option value="Europe/Moscow">Europe/Moscow</option>
<option value="Europe/Oslo">Europe/Oslo</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Europe/Prague">Europe/Prague</option>
<option value="Europe/Riga">Europe/Riga</option>
<option value="Europe/Rome">Europe/Rome</option>
<option value="Europe/Sofia">Europe/Sofia</option>
<option value="Europe/Stockholm">Europe/Stockholm</option>
<option value="Europe/Tallinn">Europe/Tallinn</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Vilnius">Europe/Vilnius</option>
<option value="Europe/Warsaw">Europe/Warsaw</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="Pacific/Auckland">Pacific/Auckland</option>
<option value="Pacific/Fiji">Pacific/Fiji</option>
<option value="Pacific/Guam">Pacific/Guam</option>
<option value="Pacific/Honolulu">Pacific/Honolulu</option>
<option value="Etc/UTC">Etc/UTC</option>
</select>
<div class="form-row two-col">
<div>
@@ -319,7 +401,47 @@ body.loaded {
<label class="small">(Seconds)</label>
</div>
</div>
<br><br><br>
<button type="button" class="collapsible-toggle" aria-expanded="false">
<span class="icon-area" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path fill="currentColor" d="M16,9V7l-2.259-0.753c-0.113-0.372-0.262-0.728-0.441-1.066l1.066-2.131L12.95,1.634L10.819,2.7 c-0.338-0.178-0.694-0.325-1.066-0.441L9,0H7L6.247,2.259C5.875,2.372,5.519,2.522,5.181,2.7L3.05,1.638L1.638,3.05l1.066,2.131 C2.522,5.519,2.375,5.875,2.259,6.247L0,7v2l2.259,0.753c0.112,0.372,0.263,0.728,0.441,1.066L1.634,12.95l1.416,1.416L5.181,13.3 c0.338,0.178,0.694,0.328,1.066,0.441L7,16h2l0.753-2.259c0.372-0.113,0.728-0.262,1.066-0.441l2.131,1.066l1.416-1.416L13.3,10.819 c0.178-0.337,0.328-0.694,0.441-1.066L16,9z M8,11c-1.656,0-3-1.344-3-3s1.344-3,3-3s3,1.344,3,3S9.656,11,8,11z" />
</svg>
</span>
<span>Advanced Settings</span>
</button>
<div class="collapsible-content" aria-hidden="true">
<!-- Custom NTP Servers -->
<label>Primary NTP Server:</label>
<input type="text" name="ntpServer1" id="ntpServer1" placeholder="Enter NTP address">
<label>Secondary NTP Server:</label>
<input type="text" name="ntpServer2" id="ntpServer2" placeholder="Enter NTP address">
<!-- 24/12hrs Clock Toggle (Styled) -->
<label style="display: flex; align-items: center; margin-top: 1.75rem;">
<span style="margin-right: 0.5em;">Display 12-hour Clock</span>
<span class="toggle-switch">
<input type="checkbox" name="twelveHourToggle" id="twelveHourToggle">
<span class="toggle-slider"></span>
</span>
</label>
<!-- Display Flip Toggle (Styled) -->
<label style="display: flex; align-items: center; margin-top: 1.75rem;">
<span style="margin-right: 0.5em;">Flip Display (180°):</span>
<span class="toggle-switch">
<input type="checkbox" name="flipDisplay" id="flipDisplay">
<span class="toggle-slider"></span>
</span>
</label>
<!-- Brightness Slider with Value -->
<label style="margin-top: 1.75rem;">Brightness: <span id="brightnessValue">10</span></label>
<input type="range" min="0" max="15" name="brightness" id="brightnessSlider" value="10" oninput="brightnessValue.textContent = brightnessSlider.value">
<br>
</div>
<input type="submit" class="primary-button" value="Save Settings">
</form>
@@ -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';
}
</script>
</body>
</html>
</html>