diff --git a/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino b/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino index c7742ba..98e787b 100644 --- a/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino +++ b/ESPTimeCast_ESP32/ESPTimeCast_ESP32.ino @@ -30,6 +30,7 @@ AsyncWebServer server(80); // --- Global Scroll Speed Settings --- const int GENERAL_SCROLL_SPEED = 85; // Default: Adjust this for Weather Description and Countdown Label (e.g., 50 for faster, 200 for slower) const int IP_SCROLL_SPEED = 115; // Default: Adjust this for the IP Address display (slower for readability) +int messageScrollSpeed = 85; // default fallback // --- Nightscout setting --- const unsigned int NIGHTSCOUT_IDLE_THRESHOLD_MIN = 10; // minutes before data is considered outdated @@ -59,6 +60,8 @@ bool showHumidity = false; bool colonBlinkEnabled = true; char ntpServer1[64] = "pool.ntp.org"; char ntpServer2[256] = "time.nist.gov"; +char customMessage[121] = ""; +char lastPersistentMessage[128] = ""; // Dimming bool dimmingEnabled = false; @@ -101,6 +104,8 @@ bool shouldFetchWeatherNow = false; unsigned long lastSwitch = 0; unsigned long lastColonBlink = 0; int displayMode = 0; // 0: Clock, 1: Weather, 2: Weather Description, 3: Countdown +int prevDisplayMode = -1; +bool clockScrollDone = false; int currentHumidity = -1; bool ntpSyncSuccessful = false; @@ -147,7 +152,11 @@ const unsigned long descriptionScrollPause = 300; // 300ms pause after scroll // --- Safe WiFi credential and API getters --- const char *getSafeSsid() { - return isAPMode ? "********" : ssid; + if (isAPMode && strlen(ssid) == 0) { + return ""; + } else { + return isAPMode ? "********" : ssid; + } } const char *getSafePassword() { @@ -179,6 +188,7 @@ textEffect_t getEffectiveScrollDirection(textEffect_t desiredDirection, bool isF return desiredDirection; } + // ----------------------------------------------------------------------------- // Configuration Load & Save // ----------------------------------------------------------------------------- @@ -252,10 +262,12 @@ void loadConfig() { strlcpy(ssid, doc["ssid"] | "", sizeof(ssid)); strlcpy(password, doc["password"] | "", sizeof(password)); - strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey)); // Corrected typo here + strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey)); strlcpy(openWeatherCity, doc["openWeatherCity"] | "", sizeof(openWeatherCity)); strlcpy(openWeatherCountry, doc["openWeatherCountry"] | "", sizeof(openWeatherCountry)); strlcpy(weatherUnits, doc["weatherUnits"] | "metric", sizeof(weatherUnits)); + strlcpy(customMessage, doc["customMessage"] | "", sizeof(customMessage)); + strlcpy(lastPersistentMessage, customMessage, sizeof(lastPersistentMessage)); clockDuration = doc["clockDuration"] | 10000; weatherDuration = doc["weatherDuration"] | 5000; strlcpy(timeZone, doc["timeZone"] | "Etc/UTC", sizeof(timeZone)); @@ -325,7 +337,6 @@ void loadConfig() { } - // ----------------------------------------------------------------------------- // WiFi Setup // ----------------------------------------------------------------------------- @@ -449,6 +460,7 @@ void connectWiFi() { } } + void clearWiFiCredentialsInConfig() { DynamicJsonDocument doc(2048); @@ -482,6 +494,7 @@ void clearWiFiCredentialsInConfig() { Serial.println(F("[SECURITY] Cleared WiFi credentials in config.json.")); } + // ----------------------------------------------------------------------------- // Time / NTP Functions // ----------------------------------------------------------------------------- @@ -584,6 +597,8 @@ void printConfigToSerial() { Serial.println(countdownLabel); Serial.print(F("Dramatic Countdown Display: ")); Serial.println(isDramaticCountdown ? "Yes" : "No"); + Serial.print(F("Custom Message: ")); + Serial.println(customMessage); Serial.print(F("Total Runtime: ")); if (totalUptimeSeconds > 0) { Serial.println(formatUptime(totalUptimeSeconds)); @@ -594,6 +609,7 @@ void printConfigToSerial() { Serial.println(); } + // ----------------------------------------------------------------------------- // Web Server and Captive Portal // ----------------------------------------------------------------------------- @@ -793,6 +809,7 @@ void setupWebServer() { Serial.println(F("[SAVE] Config verification successful.")); DynamicJsonDocument okDoc(128); + strlcpy(customMessage, doc["customMessage"] | "", sizeof(customMessage)); okDoc[F("message")] = "Saved successfully. Rebooting..."; String response; serializeJson(okDoc, response); @@ -1101,6 +1118,110 @@ void setupWebServer() { request->send(200, "application/json", "{\"ok\":true}"); }); + // --- Custom Message Endpoint --- + server.on("/set_custom_message", HTTP_POST, [](AsyncWebServerRequest *request) { + if (request->hasParam("message", true)) { + String msg = request->getParam("message", true)->value(); + msg.trim(); + + String sourceHeader = request->header("X-Source"); + bool isFromUI = (sourceHeader == "UI"); + bool isFromHA = !isFromUI; + + // --- Local speed variable (does not modify global GENERAL_SCROLL_SPEED) --- + int localSpeed = GENERAL_SCROLL_SPEED; // Default for UI messages + if (request->hasParam("speed", true)) { + localSpeed = constrain(request->getParam("speed", true)->value().toInt(), 10, 200); + Serial.printf("[MESSAGE] Custom Message scroll speed set to %d\n", localSpeed); + } + + // --- CLEAR MESSAGE --- + if (msg.length() == 0) { + if (isFromUI) { + // Web UI clear: remove everything + customMessage[0] = '\0'; + lastPersistentMessage[0] = '\0'; + displayMode = 0; + Serial.println(F("[MESSAGE] All messages cleared by UI. Returning to normal mode.")); + request->send(200, "text/plain", "CLEARED (UI)"); + + // --- SAVE CLEAR STATE --- + saveCustomMessageToConfig(""); + } else { + // HA clear: remove only temporary message + customMessage[0] = '\0'; + + if (strlen(lastPersistentMessage) > 0) { + // Restore the last persistent message + strncpy(customMessage, lastPersistentMessage, sizeof(customMessage)); + messageScrollSpeed = GENERAL_SCROLL_SPEED; // Use global speed for persistent + Serial.printf("[MESSAGE] Temporary HA message cleared. Restored persistent message: '%s' (speed=%d)\n", + customMessage, messageScrollSpeed); + request->send(200, "text/plain", "CLEARED (HA temporary, persistent restored)"); + } else { + displayMode = 0; + Serial.println(F("[MESSAGE] Temporary HA message cleared. No persistent message to restore.")); + request->send(200, "text/plain", "CLEARED (HA temporary, no persistent)"); + } + } + return; + } + + // --- SANITIZE MESSAGE --- + msg.toUpperCase(); + String filtered = ""; + for (size_t i = 0; i < msg.length(); i++) { + char c = msg[i]; + if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == ' ' || c == ':' || c == '!' || c == '\'' || c == '-' || c == '.' || c == ',' || c == '_' || c == '+' || c == '%' || c == '/' || c == '?') { + filtered += c; + } + } + + filtered.toCharArray(customMessage, sizeof(customMessage)); + + // --- STORE MESSAGE --- + if (isFromHA) { + // --- Preserve the current persistent message before overwriting --- + char prevMessage[sizeof(customMessage)]; + strlcpy(prevMessage, customMessage, sizeof(prevMessage)); + + // --- Overwrite customMessage with new temporary HA message --- + filtered.toCharArray(customMessage, sizeof(customMessage)); + messageScrollSpeed = localSpeed; // Use HA-specified scroll speed + + // --- If no persistent message stored yet, keep the previous one --- + if (strlen(lastPersistentMessage) == 0 && strlen(prevMessage) > 0) { + strlcpy(lastPersistentMessage, prevMessage, sizeof(lastPersistentMessage)); + } + + Serial.printf("[HA] Temporary HA message received: %s (persistent=%s)\n", + customMessage, lastPersistentMessage); + + } else { + // --- UI-originated message: permanent --- + filtered.toCharArray(customMessage, sizeof(customMessage)); + strlcpy(lastPersistentMessage, customMessage, sizeof(lastPersistentMessage)); + messageScrollSpeed = GENERAL_SCROLL_SPEED; // Always global for UI + + Serial.printf("[UI] Persistent message stored: %s (speed=%d)\n", + customMessage, messageScrollSpeed); + + // --- Persist to config.json immediately --- + saveCustomMessageToConfig(customMessage); + } + + // --- Activate display --- + displayMode = 6; + prevDisplayMode = 0; + + String response = String(isFromHA ? "OK (HA message, speed=" : "OK (UI message, speed=") + String(localSpeed) + ")"; + request->send(200, "text/plain", response); + } else { + Serial.println(F("[MESSAGE] Error: missing 'message' parameter in request.")); + request->send(400, "text/plain", "Missing message parameter"); + } + }); + server.on("/uptime", HTTP_GET, [](AsyncWebServerRequest *request) { if (!LittleFS.exists("/uptime.dat")) { request->send(200, "text/plain", "No uptime recorded yet."); @@ -1291,6 +1412,7 @@ void setupWebServer() { Serial.println(F("[WEBSERVER] Web server started")); } + void handleCaptivePortal(AsyncWebServerRequest *request) { Serial.print(F("[WEBSERVER] Captive Portal triggered for URL: ")); Serial.println(request->url()); @@ -1307,6 +1429,7 @@ void handleCaptivePortal(AsyncWebServerRequest *request) { } } + String normalizeWeatherDescription(String str) { // Serbian Cyrillic → Latin str.replace("а", "a"); @@ -1467,7 +1590,6 @@ bool isFiveDigitZip(const char *str) { } - // ----------------------------------------------------------------------------- // Weather Fetching and API settings // ----------------------------------------------------------------------------- @@ -1506,7 +1628,6 @@ String buildWeatherURL() { } - void fetchWeather() { Serial.println(F("[WEATHER] Fetching weather data...")); if (WiFi.status() != WL_CONNECTED) { @@ -1603,6 +1724,7 @@ void fetchWeather() { http.end(); } + void loadUptime() { if (LittleFS.exists("/uptime.dat")) { File f = LittleFS.open("/uptime.dat", "r"); @@ -1620,6 +1742,7 @@ void loadUptime() { } } + void saveUptime() { // Add runtime since boot to total unsigned long runtimeSeconds = (millis() - bootMillis) / 1000; @@ -1639,6 +1762,41 @@ void saveUptime() { } } +void saveCustomMessageToConfig(const char *msg) { + Serial.println(F("[CONFIG] Updating customMessage in config.json...")); + + DynamicJsonDocument doc(2048); + + // Load existing config.json (if present) + File configFile = LittleFS.open("/config.json", "r"); + if (configFile) { + DeserializationError err = deserializeJson(doc, configFile); + configFile.close(); + if (err) { + Serial.print(F("[CONFIG] Error reading existing config: ")); + Serial.println(err.f_str()); + } + } + + // Update only customMessage + doc["customMessage"] = msg; + + // Safely write back to config.json + if (LittleFS.exists("/config.json")) { + LittleFS.rename("/config.json", "/config.bak"); + } + + File f = LittleFS.open("/config.json", "w"); + if (!f) { + Serial.println(F("[CONFIG] ERROR: Failed to open /config.json for writing")); + return; + } + + size_t bytesWritten = serializeJson(doc, f); + f.close(); + Serial.printf("[CONFIG] Saved customMessage='%s' (%u bytes written)\n", msg, bytesWritten); +} + // Returns formatted uptime (for web UI or logs) String formatUptime(unsigned long seconds) { unsigned long days = seconds / 86400; @@ -1655,7 +1813,6 @@ String formatUptime(unsigned long seconds) { } - // ----------------------------------------------------------------------------- // Main setup() and loop() // ----------------------------------------------------------------------------- @@ -1664,9 +1821,11 @@ DisplayMode key: 0: Clock 1: Weather 2: Weather Description - 3: Countdown (NEW) + 3: Countdown + 4: Nightscout + 5: Date + 6: Custom Message */ - void setup() { Serial.begin(115200); delay(1000); @@ -1718,8 +1877,8 @@ void setup() { } - void advanceDisplayMode() { + prevDisplayMode = displayMode; int oldMode = displayMode; String ntpField = String(ntpServer2); bool nightscoutConfigured = ntpField.startsWith("https://"); @@ -1788,18 +1947,26 @@ void advanceDisplayMode() { displayMode = 0; Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Countdown)")); } - } else if (displayMode == 4) { // Nightscout -> Clock + } else if (displayMode == 4) { // Nightscout -> Custom Message + displayMode = 6; + Serial.println(F("[DISPLAY] Switching to display mode: CUSTOM MESSAGE (from Nightscout)")); + } else if (displayMode == 6) { // Custom Message -> Clock displayMode = 0; - Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Nightscout)")); + Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Custom Message)")); } // --- Common cleanup/reset logic remains the same --- + if ((displayMode == 0) && strlen(customMessage) > 0 && oldMode != 6) { + displayMode = 6; + Serial.println(F("[DISPLAY] Custom Message display before returning to CLOCK")); + } lastSwitch = millis(); } + void advanceDisplayModeSafe() { int attempts = 0; - const int MAX_ATTEMPTS = 6; // Number of possible modes + 1 + const int MAX_ATTEMPTS = 7; // Number of possible modes + 1 int startMode = displayMode; bool valid = false; do { @@ -1816,6 +1983,7 @@ void advanceDisplayModeSafe() { else if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) valid = true; else if (displayMode == 3 && countdownEnabled && !countdownFinished && ntpSyncSuccessful) valid = true; else if (displayMode == 4 && nightscoutConfigured) valid = true; + else if (displayMode == 6 && strlen(customMessage) > 0) valid = true; // If we've looped back to where we started, break to avoid infinite loop if (displayMode == startMode) break; @@ -1831,6 +1999,7 @@ void advanceDisplayModeSafe() { lastSwitch = millis(); } + //config save after countdown finishes bool saveCountdownConfig(bool enabled, time_t targetTimestamp, const String &label) { DynamicJsonDocument doc(2048); @@ -1894,7 +2063,6 @@ void loop() { const unsigned long fetchInterval = 300000; // 5 minutes - // AP Mode animation static unsigned long apAnimTimer = 0; static int apAnimFrame = 0; @@ -1977,7 +2145,6 @@ void loop() { } - // --- IMMEDIATE COUNTDOWN FINISH TRIGGER --- if (countdownEnabled && !countdownFinished && ntpSyncSuccessful && countdownTargetTimestamp > 0 && now_time >= countdownTargetTimestamp) { countdownFinished = true; @@ -2011,7 +2178,6 @@ void loop() { } - // --- BRIGHTNESS/OFF CHECK --- if (brightness == -1) { if (!displayOff) { @@ -2024,7 +2190,6 @@ void loop() { } - // --- NTP State Machine --- switch (ntpState) { case NTP_IDLE: break; @@ -2097,7 +2262,6 @@ void loop() { } - // --- MODIFIED WEATHER FETCHING LOGIC --- if (WiFi.status() == WL_CONNECTED) { if (!weatherFetchInitiated || shouldFetchWeatherNow || (millis() - lastFetch > fetchInterval)) { @@ -2177,11 +2341,6 @@ void loop() { } - - // Persistent variables (declare near top of file or loop) - static int prevDisplayMode = -1; - static bool clockScrollDone = false; - // --- CLOCK Display Mode --- if (displayMode == 0) { P.setCharSpacing(0); @@ -2231,6 +2390,8 @@ void loop() { shouldScrollIn = true; // first boot or other special modes } else if (prevDisplayMode == 2 && weatherDescription.length() > 8) { shouldScrollIn = true; // only scroll in if weather was scrolling + } else if (prevDisplayMode == 6) { + shouldScrollIn = true; // scroll in when coming from custom message } if (shouldScrollIn && !clockScrollDone) { @@ -2259,10 +2420,6 @@ void loop() { } } - // --- update prevDisplayMode --- - prevDisplayMode = displayMode; - - // --- WEATHER Display Mode --- static bool weatherWasAvailable = false; @@ -2299,12 +2456,22 @@ void loop() { } - - // --- WEATHER DESCRIPTION Display Mode --- if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) { String desc = weatherDescription; + // --- Check if humidity is actually visible --- + bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0; + + // --- Conditional padding --- + bool addPadding = false; + if (prevDisplayMode == 1 && humidityVisible) { + addPadding = true; + } + if (addPadding) { + desc = " " + desc; // 4-space padding before scrolling + } + // prepare safe buffer static char descBuffer[128]; // large enough for OWM translations desc.toCharArray(descBuffer, sizeof(descBuffer)); @@ -2617,10 +2784,17 @@ void loop() { } String fullString = String(buf); + bool addPadding = false; + bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0; - // --- Add a leading space only if showDayOfWeek is true --- - if (showDayOfWeek) { - fullString = " " + fullString; + // Padding logic + if (prevDisplayMode == 0 && (showDayOfWeek || colonBlinkEnabled)) { + addPadding = true; + } else if (prevDisplayMode == 1 && humidityVisible) { + addPadding = true; + } + if (addPadding) { + fullString = " " + fullString; // 4 spaces } // Display the full string and scroll it @@ -2898,6 +3072,53 @@ void loop() { } } + + // --- Custom Message Display Mode (displayMode == 6) --- + if (displayMode == 6) { + if (strlen(customMessage) == 0) { + advanceDisplayMode(); + yield(); + return; + } + + String msg = String(customMessage); + + // Replace standard digits 0–9 with your custom font character codes + for (int i = 0; i < msg.length(); i++) { + if (isDigit(msg[i])) { + int num = msg[i] - '0'; // 0–9 + msg[i] = 145 + ((num + 9) % 10); // Maps 0→154, 1→145, ... 9→153 + } + } + + // --- Determine if we need left padding based on previous mode --- + bool addPadding = false; + bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0; + + // If coming from CLOCK mode + if (prevDisplayMode == 0 && (showDayOfWeek || colonBlinkEnabled)) { + addPadding = true; + } else if (prevDisplayMode == 1 && humidityVisible) { + addPadding = true; + } + // Apply padding (4 spaces) if needed + if (addPadding) { + msg = " " + msg; + } + + // --- Display scrolling message --- + P.setTextAlignment(PA_LEFT); + P.setCharSpacing(1); + textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay); + extern int messageScrollSpeed; // declare globally once at the top of your sketch + P.displayScroll(msg.c_str(), PA_LEFT, actualScrollDirection, messageScrollSpeed); + while (!P.displayAnimate()) yield(); + P.setTextAlignment(PA_CENTER); + advanceDisplayMode(); + yield(); + return; + } + unsigned long currentMillis = millis(); unsigned long runtimeSeconds = (currentMillis - bootMillis) / 1000; unsigned long currentTotal = totalUptimeSeconds + runtimeSeconds; diff --git a/ESPTimeCast_ESP32/data/index.html b/ESPTimeCast_ESP32/data/index.html index a1a326a..4b8fcc6 100644 --- a/ESPTimeCast_ESP32/data/index.html +++ b/ESPTimeCast_ESP32/data/index.html @@ -224,6 +224,13 @@ textarea::placeholder { box-shadow: none; } + .button-row { + display: flex; + margin-top: 0.5rem; + gap: 0.8rem; + justify-content: center; +} + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } @@ -311,6 +318,7 @@ textarea::placeholder { +

WiFi Settings

@@ -322,26 +330,7 @@ textarea::placeholder { Show Password -

Weather Settings

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

Clock Settings

@@ -465,7 +454,6 @@ textarea::placeholder { -
@@ -478,153 +466,188 @@ textarea::placeholder {
- + +
+ Location format examples: City, Country Code - Osaka, JP | ZIP, Country Code - 94040, US | Latitude, Longitude - 34.6937, 135.5023 +
+ +
+

Custom Message

+ +
Allowed characters: A–Z, 0–9, space, and : ! ' - . ? , _ + % /
+
+
+ + +
+ + + + - @@ -641,6 +664,19 @@ let isAPMode = false; // Set initial value display for brightness document.addEventListener('DOMContentLoaded', function() { brightnessValue.textContent = brightnessSlider.value; + + // Sanitize input LIVE for customMessage + var customMsgInput = document.getElementById('customMessage'); + if (customMsgInput) { + customMsgInput.addEventListener('input', function() { + let before = this.value; + let after = before.toUpperCase().replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, ''); + if (before !== after) { + this.value = after; + } + }); + } + }); // Show/Hide Password toggle @@ -687,6 +723,12 @@ window.onload = function () { document.querySelector('.geo-note').style.display = 'block'; document.getElementById('geo-button').classList.add('geo-disabled'); document.getElementById('geo-button').disabled = true; + + document.querySelector('.cmsg1').classList.add('geo-disabled'); + document.querySelector('.cmsg1').disabled = true; + + document.querySelector('.cmsg2').classList.add('geo-disabled'); + document.querySelector('.cmsg2').disabled = true; } document.getElementById('ssid').value = data.ssid || ''; document.getElementById('password').value = data.password || ''; @@ -786,6 +828,10 @@ window.onload = function () { }); // Set initial state of fields when page loads setCountdownFieldsEnabled(countdownEnabledEl.checked); + + if (data.customMessage !== undefined) { + document.getElementById('customMessage').value = data.customMessage; +} // Auto-detect browser's timezone if not set in config if (!data.timeZone) { try { @@ -892,6 +938,17 @@ async function submitConfig(event) { params.append(pair[0], pair[1]); } + // Sanitize and set customMessage before sending + const customMsgInput = document.getElementById('customMessage'); + if (customMsgInput) { + customMsgInput.value = customMsgInput.value + .toUpperCase() + .replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, '') + .replace(/\s+/g, ' ') + .trim() + .substring(0, 120); + } + // Check AP mode status let isAPMode = false; try { @@ -1415,6 +1472,62 @@ function updateUptimeDisplay() { fetchUptime(); +function sendCustomMessage() { + const input = document.getElementById('customMessage'); + let message = input.value + .toUpperCase() + .replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, '') + .replace(/\s+/g, ' ') + .trim() + .substring(0, 120); + + fetch('/set_custom_message', { + method: 'POST', + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Source": "UI" + }, + body: "message=" + encodeURIComponent(message) + }) + .then(res => res.text()) + .then(res => { + showSavingModal(""); + updateSavingModal("✅ Message sent successfully!

Now displaying your custom message.", false); + setTimeout(hideSavingModal, 2000); + }) + .catch(err => { + console.error("Error sending custom message:", err); + showSavingModal(""); + updateSavingModal("⚠️ Failed to send message.

Check connection.", false); + setTimeout(hideSavingModal, 3000); + }); +} + +function clearCustomMessage() { + fetch('/set_custom_message', { + method: 'POST', + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Source": "UI" // <-- Add this + }, + body: "message=" + }) + .then(res => res.text()) + .then(res => { + document.getElementById('customMessage').value = ''; + showSavingModal(""); + updateSavingModal("✅ Custom message cleared.

Display reverted to normal.", false); + setTimeout(hideSavingModal, 2000); + }) + .catch(err => { + console.error("Error clearing custom message:", err); + showSavingModal(""); + updateSavingModal("⚠️ Failed to clear message.

Check connection.", false); + setTimeout(hideSavingModal, 3000); + }); +} + + \ No newline at end of file diff --git a/ESPTimeCast_ESP32/mfactoryfont.h b/ESPTimeCast_ESP32/mfactoryfont.h index b418c07..e74e403 100644 --- a/ESPTimeCast_ESP32/mfactoryfont.h +++ b/ESPTimeCast_ESP32/mfactoryfont.h @@ -47,7 +47,7 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = 17, 130, 186, 198, 254, 134, 234, 134, 254, 250, 130, 250, 254, 134, 234, 134, 254, 124, // 41 - ')' 20, 250, 130, 250, 254, 130, 170, 186, 254, 130, 250, 226, 250, 134, 254, 130, 234, 234, 246, 254, 124, // 42 - '*' 5, 8, 8, 62, 8, 8, // 43 - '+' - 1, 192, // 44 - ',' + 2, 128, 64, // 44 - ',' 2, 8, 8, // 45 - '-' 1, 64, // 46 - '.' 3, 96, 24, 6, // 47 - '/' diff --git a/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino b/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino index 81870b7..0487c68 100644 --- a/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino +++ b/ESPTimeCast_ESP8266/ESPTimeCast_ESP8266.ino @@ -28,9 +28,9 @@ MD_Parola P = MD_Parola(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES); AsyncWebServer server(80); // --- Global Scroll Speed Settings --- -const int GENERAL_SCROLL_SPEED = 85; // Default: Adjust this for Weather Description and Countdown Label (e.g., 50 for faster, 200 for slower) -const int IP_SCROLL_SPEED = 115; // Default: Adjust this for the IP Address display (slower for readability) - +const int GENERAL_SCROLL_SPEED = 85; // Default: Adjust this for Weather Description and Countdown Label (e.g., 50 for faster, 200 for slower) +const int IP_SCROLL_SPEED = 115; // Default: Adjust this for the IP Address display (slower for readability) +int messageScrollSpeed = 85; // default fallback // --- Nightscout setting --- const unsigned int NIGHTSCOUT_IDLE_THRESHOLD_MIN = 10; // minutes before data is considered outdated @@ -60,6 +60,8 @@ bool showHumidity = false; bool colonBlinkEnabled = true; char ntpServer1[64] = "pool.ntp.org"; char ntpServer2[256] = "time.nist.gov"; +char customMessage[121] = ""; +char lastPersistentMessage[128] = ""; // Dimming bool dimmingEnabled = false; @@ -102,6 +104,8 @@ bool shouldFetchWeatherNow = false; unsigned long lastSwitch = 0; unsigned long lastColonBlink = 0; int displayMode = 0; // 0: Clock, 1: Weather, 2: Weather Description, 3: Countdown +int prevDisplayMode = -1; +bool clockScrollDone = false; int currentHumidity = -1; bool ntpSyncSuccessful = false; @@ -148,7 +152,11 @@ const unsigned long descriptionScrollPause = 300; // 300ms pause after scroll // --- Safe WiFi credential and API getters --- const char *getSafeSsid() { - return isAPMode ? "********" : ssid; + if (isAPMode && strlen(ssid) == 0) { + return ""; + } else { + return isAPMode ? "********" : ssid; + } } const char *getSafePassword() { @@ -253,10 +261,12 @@ void loadConfig() { strlcpy(ssid, doc["ssid"] | "", sizeof(ssid)); strlcpy(password, doc["password"] | "", sizeof(password)); - strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey)); // Corrected typo here + strlcpy(openWeatherApiKey, doc["openWeatherApiKey"] | "", sizeof(openWeatherApiKey)); strlcpy(openWeatherCity, doc["openWeatherCity"] | "", sizeof(openWeatherCity)); strlcpy(openWeatherCountry, doc["openWeatherCountry"] | "", sizeof(openWeatherCountry)); strlcpy(weatherUnits, doc["weatherUnits"] | "metric", sizeof(weatherUnits)); + strlcpy(customMessage, doc["customMessage"] | "", sizeof(customMessage)); + strlcpy(lastPersistentMessage, customMessage, sizeof(lastPersistentMessage)); clockDuration = doc["clockDuration"] | 10000; weatherDuration = doc["weatherDuration"] | 5000; strlcpy(timeZone, doc["timeZone"] | "Etc/UTC", sizeof(timeZone)); @@ -327,7 +337,6 @@ void loadConfig() { } - // ----------------------------------------------------------------------------- // WiFi Setup // ----------------------------------------------------------------------------- @@ -484,6 +493,7 @@ void clearWiFiCredentialsInConfig() { Serial.println(F("[SECURITY] Cleared WiFi credentials in config.json.")); } + // ----------------------------------------------------------------------------- // Time / NTP Functions // ----------------------------------------------------------------------------- @@ -521,6 +531,7 @@ void setupTime() { } } + // ----------------------------------------------------------------------------- // Utility // ----------------------------------------------------------------------------- @@ -586,6 +597,8 @@ void printConfigToSerial() { Serial.println(countdownLabel); Serial.print(F("Dramatic Countdown Display: ")); Serial.println(isDramaticCountdown ? "Yes" : "No"); + Serial.print(F("Custom Message: ")); + Serial.println(customMessage); Serial.print(F("Total Runtime: ")); if (totalUptimeSeconds > 0) { Serial.println(formatUptime(totalUptimeSeconds)); @@ -795,6 +808,7 @@ void setupWebServer() { Serial.println(F("[SAVE] Config verification successful.")); DynamicJsonDocument okDoc(128); + strlcpy(customMessage, doc["customMessage"] | "", sizeof(customMessage)); okDoc[F("message")] = "Saved successfully. Rebooting..."; String response; serializeJson(okDoc, response); @@ -1105,6 +1119,110 @@ void setupWebServer() { request->send(200, "application/json", "{\"ok\":true}"); }); + // --- Custom Message Endpoint --- + server.on("/set_custom_message", HTTP_POST, [](AsyncWebServerRequest *request) { + if (request->hasParam("message", true)) { + String msg = request->getParam("message", true)->value(); + msg.trim(); + + String sourceHeader = request->header("X-Source"); + bool isFromUI = (sourceHeader == "UI"); + bool isFromHA = !isFromUI; + + // --- Local speed variable (does not modify global GENERAL_SCROLL_SPEED) --- + int localSpeed = GENERAL_SCROLL_SPEED; // Default for UI messages + if (request->hasParam("speed", true)) { + localSpeed = constrain(request->getParam("speed", true)->value().toInt(), 10, 200); + Serial.printf("[MESSAGE] Custom Message scroll speed set to %d\n", localSpeed); + } + + // --- CLEAR MESSAGE --- + if (msg.length() == 0) { + if (isFromUI) { + // Web UI clear: remove everything + customMessage[0] = '\0'; + lastPersistentMessage[0] = '\0'; + displayMode = 0; + Serial.println(F("[MESSAGE] All messages cleared by UI. Returning to normal mode.")); + request->send(200, "text/plain", "CLEARED (UI)"); + + // --- SAVE CLEAR STATE --- + saveCustomMessageToConfig(""); + } else { + // HA clear: remove only temporary message + customMessage[0] = '\0'; + + if (strlen(lastPersistentMessage) > 0) { + // Restore the last persistent message + strncpy(customMessage, lastPersistentMessage, sizeof(customMessage)); + messageScrollSpeed = GENERAL_SCROLL_SPEED; // Use global speed for persistent + Serial.printf("[MESSAGE] Temporary HA message cleared. Restored persistent message: '%s' (speed=%d)\n", + customMessage, messageScrollSpeed); + request->send(200, "text/plain", "CLEARED (HA temporary, persistent restored)"); + } else { + displayMode = 0; + Serial.println(F("[MESSAGE] Temporary HA message cleared. No persistent message to restore.")); + request->send(200, "text/plain", "CLEARED (HA temporary, no persistent)"); + } + } + return; + } + + // --- SANITIZE MESSAGE --- + msg.toUpperCase(); + String filtered = ""; + for (size_t i = 0; i < msg.length(); i++) { + char c = msg[i]; + if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == ' ' || c == ':' || c == '!' || c == '\'' || c == '-' || c == '.' || c == ',' || c == '_' || c == '+' || c == '%' || c == '/' || c == '?') { + filtered += c; + } + } + + filtered.toCharArray(customMessage, sizeof(customMessage)); + + // --- STORE MESSAGE --- + if (isFromHA) { + // --- Preserve the current persistent message before overwriting --- + char prevMessage[sizeof(customMessage)]; + strlcpy(prevMessage, customMessage, sizeof(prevMessage)); + + // --- Overwrite customMessage with new temporary HA message --- + filtered.toCharArray(customMessage, sizeof(customMessage)); + messageScrollSpeed = localSpeed; // Use HA-specified scroll speed + + // --- If no persistent message stored yet, keep the previous one --- + if (strlen(lastPersistentMessage) == 0 && strlen(prevMessage) > 0) { + strlcpy(lastPersistentMessage, prevMessage, sizeof(lastPersistentMessage)); + } + + Serial.printf("[HA] Temporary HA message received: %s (persistent=%s)\n", + customMessage, lastPersistentMessage); + + } else { + // --- UI-originated message: permanent --- + filtered.toCharArray(customMessage, sizeof(customMessage)); + strlcpy(lastPersistentMessage, customMessage, sizeof(lastPersistentMessage)); + messageScrollSpeed = GENERAL_SCROLL_SPEED; // Always global for UI + + Serial.printf("[UI] Persistent message stored: %s (speed=%d)\n", + customMessage, messageScrollSpeed); + + // --- Persist to config.json immediately --- + saveCustomMessageToConfig(customMessage); + } + + // --- Activate display --- + displayMode = 6; + prevDisplayMode = 0; + + String response = String(isFromHA ? "OK (HA message, speed=" : "OK (UI message, speed=") + String(localSpeed) + ")"; + request->send(200, "text/plain", response); + } else { + Serial.println(F("[MESSAGE] Error: missing 'message' parameter in request.")); + request->send(400, "text/plain", "Missing message parameter"); + } + }); + server.on("/uptime", HTTP_GET, [](AsyncWebServerRequest *request) { if (!LittleFS.exists("/uptime.dat")) { request->send(200, "text/plain", "No uptime recorded yet."); @@ -1604,6 +1722,7 @@ void fetchWeather() { http.end(); } + void loadUptime() { if (LittleFS.exists("/uptime.dat")) { File f = LittleFS.open("/uptime.dat", "r"); @@ -1640,6 +1759,41 @@ void saveUptime() { } } +void saveCustomMessageToConfig(const char *msg) { + Serial.println(F("[CONFIG] Updating customMessage in config.json...")); + + DynamicJsonDocument doc(2048); + + // Load existing config.json (if present) + File configFile = LittleFS.open("/config.json", "r"); + if (configFile) { + DeserializationError err = deserializeJson(doc, configFile); + configFile.close(); + if (err) { + Serial.print(F("[CONFIG] Error reading existing config: ")); + Serial.println(err.f_str()); + } + } + + // Update only customMessage + doc["customMessage"] = msg; + + // Safely write back to config.json + if (LittleFS.exists("/config.json")) { + LittleFS.rename("/config.json", "/config.bak"); + } + + File f = LittleFS.open("/config.json", "w"); + if (!f) { + Serial.println(F("[CONFIG] ERROR: Failed to open /config.json for writing")); + return; + } + + size_t bytesWritten = serializeJson(doc, f); + f.close(); + Serial.printf("[CONFIG] Saved customMessage='%s' (%u bytes written)\n", msg, bytesWritten); +} + // Returns formatted uptime (for web UI or logs) String formatUptime(unsigned long seconds) { unsigned long days = seconds / 86400; @@ -1656,7 +1810,6 @@ String formatUptime(unsigned long seconds) { } - // ----------------------------------------------------------------------------- // Main setup() and loop() // ----------------------------------------------------------------------------- @@ -1667,8 +1820,9 @@ DisplayMode key: 2: Weather Description 3: Countdown 4: Nightscout + 5: Date + 6: Custom Message */ - void setup() { Serial.begin(115200); delay(1000); @@ -1719,7 +1873,9 @@ void setup() { saveUptime(); } + void advanceDisplayMode() { + prevDisplayMode = displayMode; int oldMode = displayMode; String ntpField = String(ntpServer2); bool nightscoutConfigured = ntpField.startsWith("https://"); @@ -1788,18 +1944,26 @@ void advanceDisplayMode() { displayMode = 0; Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Countdown)")); } - } else if (displayMode == 4) { // Nightscout -> Clock + } else if (displayMode == 4) { // Nightscout -> Custom Message + displayMode = 6; + Serial.println(F("[DISPLAY] Switching to display mode: CUSTOM MESSAGE (from Nightscout)")); + } else if (displayMode == 6) { // Custom Message -> Clock displayMode = 0; - Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Nightscout)")); + Serial.println(F("[DISPLAY] Switching to display mode: CLOCK (from Custom Message)")); } // --- Common cleanup/reset logic remains the same --- + if ((displayMode == 0) && strlen(customMessage) > 0 && oldMode != 6) { + displayMode = 6; + Serial.println(F("[DISPLAY] Custom Message display before returning to CLOCK")); + } lastSwitch = millis(); } + void advanceDisplayModeSafe() { int attempts = 0; - const int MAX_ATTEMPTS = 6; // Number of possible modes + 1 + const int MAX_ATTEMPTS = 7; // Number of possible modes + 1 int startMode = displayMode; bool valid = false; do { @@ -1816,6 +1980,7 @@ void advanceDisplayModeSafe() { else if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) valid = true; else if (displayMode == 3 && countdownEnabled && !countdownFinished && ntpSyncSuccessful) valid = true; else if (displayMode == 4 && nightscoutConfigured) valid = true; + else if (displayMode == 6 && strlen(customMessage) > 0) valid = true; // If we've looped back to where we started, break to avoid infinite loop if (displayMode == startMode) break; @@ -1831,6 +1996,7 @@ void advanceDisplayModeSafe() { lastSwitch = millis(); } + //config save after countdown finishes bool saveCountdownConfig(bool enabled, time_t targetTimestamp, const String &label) { DynamicJsonDocument doc(2048); @@ -1894,7 +2060,6 @@ void loop() { const unsigned long fetchInterval = 300000; // 5 minutes - // AP Mode animation static unsigned long apAnimTimer = 0; static int apAnimFrame = 0; @@ -2086,7 +2251,6 @@ void loop() { } - // 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) { @@ -2094,7 +2258,6 @@ void loop() { } - // --- MODIFIED WEATHER FETCHING LOGIC --- if (WiFi.status() == WL_CONNECTED) { if (!weatherFetchInitiated || shouldFetchWeatherNow || (millis() - lastFetch > fetchInterval)) { @@ -2117,7 +2280,6 @@ void loop() { } - const char *const *daysOfTheWeek = getDaysOfWeek(language); const char *daySymbol = daysOfTheWeek[timeinfo.tm_wday]; @@ -2174,9 +2336,6 @@ void loop() { advanceDisplayMode(); } - // Persistent variables (declare near top of file or loop) - static int prevDisplayMode = -1; - static bool clockScrollDone = false; // --- CLOCK Display Mode --- if (displayMode == 0) { @@ -2226,6 +2385,8 @@ void loop() { shouldScrollIn = true; // first boot or other special modes } else if (prevDisplayMode == 2 && weatherDescription.length() > 8) { shouldScrollIn = true; // only scroll in if weather was scrolling + } else if (prevDisplayMode == 6) { + shouldScrollIn = true; // scroll in when coming from custom message } if (shouldScrollIn && !clockScrollDone) { @@ -2254,9 +2415,6 @@ void loop() { } } - // --- update prevDisplayMode --- - prevDisplayMode = displayMode; - // --- WEATHER Display Mode --- static bool weatherWasAvailable = false; @@ -2297,6 +2455,18 @@ void loop() { if (displayMode == 2 && showWeatherDescription && weatherAvailable && weatherDescription.length() > 0) { String desc = weatherDescription; + // --- Check if humidity is actually visible --- + bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0; + + // --- Conditional padding --- + bool addPadding = false; + if (prevDisplayMode == 1 && humidityVisible) { + addPadding = true; + } + if (addPadding) { + desc = " " + desc; // 4-space padding before scrolling + } + // prepare safe buffer static char descBuffer[128]; // large enough for OWM translations desc.toCharArray(descBuffer, sizeof(descBuffer)); @@ -2609,10 +2779,17 @@ void loop() { } String fullString = String(buf); + bool addPadding = false; + bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0; - // --- Add a leading space only if showDayOfWeek is true --- - if (showDayOfWeek) { - fullString = " " + fullString; + // Padding logic + if (prevDisplayMode == 0 && (showDayOfWeek || colonBlinkEnabled)) { + addPadding = true; + } else if (prevDisplayMode == 1 && humidityVisible) { + addPadding = true; + } + if (addPadding) { + fullString = " " + fullString; // 4 spaces } // Display the full string and scroll it @@ -2890,6 +3067,53 @@ void loop() { } } + + // --- Custom Message Display Mode (displayMode == 6) --- + if (displayMode == 6) { + if (strlen(customMessage) == 0) { + advanceDisplayMode(); + yield(); + return; + } + + String msg = String(customMessage); + + // Replace standard digits 0–9 with your custom font character codes + for (int i = 0; i < msg.length(); i++) { + if (isDigit(msg[i])) { + int num = msg[i] - '0'; // 0–9 + msg[i] = 145 + ((num + 9) % 10); // Maps 0→154, 1→145, ... 9→153 + } + } + + // --- Determine if we need left padding based on previous mode --- + bool addPadding = false; + bool humidityVisible = showHumidity && weatherAvailable && strlen(openWeatherApiKey) == 32 && strlen(openWeatherCity) > 0 && strlen(openWeatherCountry) > 0; + + // If coming from CLOCK mode + if (prevDisplayMode == 0 && (showDayOfWeek || colonBlinkEnabled)) { + addPadding = true; + } else if (prevDisplayMode == 1 && humidityVisible) { + addPadding = true; + } + // Apply padding (4 spaces) if needed + if (addPadding) { + msg = " " + msg; + } + + // --- Display scrolling message --- + P.setTextAlignment(PA_LEFT); + P.setCharSpacing(1); + textEffect_t actualScrollDirection = getEffectiveScrollDirection(PA_SCROLL_LEFT, flipDisplay); + extern int messageScrollSpeed; // declare globally once at the top of your sketch + P.displayScroll(msg.c_str(), PA_LEFT, actualScrollDirection, messageScrollSpeed); + while (!P.displayAnimate()) yield(); + P.setTextAlignment(PA_CENTER); + advanceDisplayMode(); + yield(); + return; + } + unsigned long currentMillis = millis(); unsigned long runtimeSeconds = (currentMillis - bootMillis) / 1000; unsigned long currentTotal = totalUptimeSeconds + runtimeSeconds; diff --git a/ESPTimeCast_ESP8266/data/index.html b/ESPTimeCast_ESP8266/data/index.html index a1a326a..4b8fcc6 100644 --- a/ESPTimeCast_ESP8266/data/index.html +++ b/ESPTimeCast_ESP8266/data/index.html @@ -224,6 +224,13 @@ textarea::placeholder { box-shadow: none; } + .button-row { + display: flex; + margin-top: 0.5rem; + gap: 0.8rem; + justify-content: center; +} + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } @@ -311,6 +318,7 @@ textarea::placeholder { +

WiFi Settings

@@ -322,26 +330,7 @@ textarea::placeholder { Show Password -

Weather Settings

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

Clock Settings

@@ -465,7 +454,6 @@ textarea::placeholder { -
@@ -478,153 +466,188 @@ textarea::placeholder {
- + +
+ Location format examples: City, Country Code - Osaka, JP | ZIP, Country Code - 94040, US | Latitude, Longitude - 34.6937, 135.5023 +
+ +
+

Custom Message

+ +
Allowed characters: A–Z, 0–9, space, and : ! ' - . ? , _ + % /
+
+
+ + +
+ + + + - @@ -641,6 +664,19 @@ let isAPMode = false; // Set initial value display for brightness document.addEventListener('DOMContentLoaded', function() { brightnessValue.textContent = brightnessSlider.value; + + // Sanitize input LIVE for customMessage + var customMsgInput = document.getElementById('customMessage'); + if (customMsgInput) { + customMsgInput.addEventListener('input', function() { + let before = this.value; + let after = before.toUpperCase().replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, ''); + if (before !== after) { + this.value = after; + } + }); + } + }); // Show/Hide Password toggle @@ -687,6 +723,12 @@ window.onload = function () { document.querySelector('.geo-note').style.display = 'block'; document.getElementById('geo-button').classList.add('geo-disabled'); document.getElementById('geo-button').disabled = true; + + document.querySelector('.cmsg1').classList.add('geo-disabled'); + document.querySelector('.cmsg1').disabled = true; + + document.querySelector('.cmsg2').classList.add('geo-disabled'); + document.querySelector('.cmsg2').disabled = true; } document.getElementById('ssid').value = data.ssid || ''; document.getElementById('password').value = data.password || ''; @@ -786,6 +828,10 @@ window.onload = function () { }); // Set initial state of fields when page loads setCountdownFieldsEnabled(countdownEnabledEl.checked); + + if (data.customMessage !== undefined) { + document.getElementById('customMessage').value = data.customMessage; +} // Auto-detect browser's timezone if not set in config if (!data.timeZone) { try { @@ -892,6 +938,17 @@ async function submitConfig(event) { params.append(pair[0], pair[1]); } + // Sanitize and set customMessage before sending + const customMsgInput = document.getElementById('customMessage'); + if (customMsgInput) { + customMsgInput.value = customMsgInput.value + .toUpperCase() + .replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, '') + .replace(/\s+/g, ' ') + .trim() + .substring(0, 120); + } + // Check AP mode status let isAPMode = false; try { @@ -1415,6 +1472,62 @@ function updateUptimeDisplay() { fetchUptime(); +function sendCustomMessage() { + const input = document.getElementById('customMessage'); + let message = input.value + .toUpperCase() + .replace(/[^A-Z0-9 :!'\-.,_\+%\/?]/g, '') + .replace(/\s+/g, ' ') + .trim() + .substring(0, 120); + + fetch('/set_custom_message', { + method: 'POST', + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Source": "UI" + }, + body: "message=" + encodeURIComponent(message) + }) + .then(res => res.text()) + .then(res => { + showSavingModal(""); + updateSavingModal("✅ Message sent successfully!

Now displaying your custom message.", false); + setTimeout(hideSavingModal, 2000); + }) + .catch(err => { + console.error("Error sending custom message:", err); + showSavingModal(""); + updateSavingModal("⚠️ Failed to send message.

Check connection.", false); + setTimeout(hideSavingModal, 3000); + }); +} + +function clearCustomMessage() { + fetch('/set_custom_message', { + method: 'POST', + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Source": "UI" // <-- Add this + }, + body: "message=" + }) + .then(res => res.text()) + .then(res => { + document.getElementById('customMessage').value = ''; + showSavingModal(""); + updateSavingModal("✅ Custom message cleared.

Display reverted to normal.", false); + setTimeout(hideSavingModal, 2000); + }) + .catch(err => { + console.error("Error clearing custom message:", err); + showSavingModal(""); + updateSavingModal("⚠️ Failed to clear message.

Check connection.", false); + setTimeout(hideSavingModal, 3000); + }); +} + + \ No newline at end of file diff --git a/ESPTimeCast_ESP8266/mfactoryfont.h b/ESPTimeCast_ESP8266/mfactoryfont.h index 11ad9d9..97112a9 100644 --- a/ESPTimeCast_ESP8266/mfactoryfont.h +++ b/ESPTimeCast_ESP8266/mfactoryfont.h @@ -47,7 +47,7 @@ MD_MAX72XX::fontType_t mFactory[] PROGMEM = 17, 130, 186, 198, 254, 134, 234, 134, 254, 250, 130, 250, 254, 134, 234, 134, 254, 124, // 41 - ')' 20, 250, 130, 250, 254, 130, 170, 186, 254, 130, 250, 226, 250, 134, 254, 130, 234, 234, 246, 254, 124, // 42 - '*' 5, 8, 8, 62, 8, 8, // 43 - '+' - 1, 192, // 44 - ',' + 2, 128, 64, // 44 - ',' 2, 8, 8, // 45 - '-' 1, 64, // 46 - '.' 3, 96, 24, 6, // 47 - '/'