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 {