#include #include #include #include #include #include #include // For non-volatile storage #include // For captive portal #include // For provisioning web server // ======================================================================== // Dynamic Electricity Ticker for XIAO ESP32C3 - VERSION 5.5 (15-MINUTE DETAIL MODE) // ======================================================================== // // This code fetches real-time day-ahead electricity prices from // Energy-Charts.info for Slovenia, calculates the final consumer price // with power company fees and VAT, and displays the rates on a 20x4 LCD. // // VERSION 5.5 NEW FEATURES: // - **15-MINUTE DETAIL MODE:** Primary display now shows current hour average, // followed by the 4 individual 15-minute values in compact format (XX XX XX XX), // then the next 2 hours. This provides both hourly overview and 15-min precision. // - **LED responds to current 15-minute interval** instead of hourly average // for more precise price indication. // - **Compact price format:** 15-minute values shown as 2-digit hundredths // (e.g., "+99 -07 24 11" for prices like 0.99, -0.07, 0.24, 0.11 EUR/kWh) // // All previous features from V5.4 are retained (aggressive retry, state enforcement, etc.) // // ------------------------------------------------------------------------ // Hardware Configuration & Wiring Instructions: // ------------------------------------------------------------------------ // // PUSHBUTTON (DEFAULT CONFIGURATION): // - Connect one leg to GPIO 4, other to GND // - Uses internal pull-up, no external resistor needed // // ALTERNATIVE: TTP223 CAPACITIVE TOUCH BUTTON: // - If you prefer a capacitive touch button instead of mechanical pushbutton: // WIRING: // - VCC to 3.3V power rail // - GND to GND // - OUT to GPIO 4 (same pin as pushbutton) // CODE CHANGES REQUIRED: // - Find line: "int reading = digitalRead(buttonPin);" // - Change to: "int reading = !digitalRead(buttonPin);" // - This inverts the logic since TTP223 outputs HIGH when touched, // while pushbutton pulls LOW when pressed // - No other changes needed - all timing and debounce logic remains the same // BENEFITS: // - No mechanical wear, sealed operation // - Can be mounted behind thin non-metallic panels // - More modern, sleek appearance // // PRESENCE SENSOR (RCWL-0516): // - VCC to 3.3V, GND to GND, OUT to GPIO 9 // - REQUIRED: 10kOhm pull-down resistor between GPIO 9 and GND // - Can be hidden behind non-metallic surfaces // // WHITE LED: // - Connect to GPIO 5 (with appropriate resistor) // - Provides visual price indication // // ======================================================================== // ======================================================================== // CONFIGURATION CONSTANTS // ======================================================================== struct Config { static const int JSON_BUFFER_SIZE = 4096; // Reduced from 8192 static const int HTTP_TIMEOUT = 10000; // 10 second timeout static const int HTTP_CONNECT_TIMEOUT = 5000; // 5 second connection timeout static const int WIFI_RETRY_MAX = 20; // Reduced from 50 static const int NTP_TIMEOUT = 15000; // Reduced from 30000 static const int LOOP_UPDATE_INTERVAL = 100; // Non-critical operations every 100ms }; // Debug level control: 0=none, 1=errors, 2=info, 3=verbose #define DEBUG_LEVEL 2 // Changed to 2 for better troubleshooting #define HAS_WHITE_LED true // Set based on your hardware configuration // REMOVED: Hard-coded NETWORK CREDENTIALS // We will now load them from NVS or set up a provisioning portal. // ======================================================================== // HARDWARE PIN DEFINITIONS // ======================================================================== const int whiteLedPin = 5; const int builtinLedPin = 21; // Correct pin for XIAO ESP32C3 built-in LED const int buttonPin = 4; // GPIO pin for the pushbutton const int presencePin = 9; // GPIO pin for the presence sensor // ======================================================================== // LED CONTROL VARIABLES // ======================================================================== bool ledsConnected = HAS_WHITE_LED; bool areLedsOn = false; // Variables for LED effects int breatheValue = 0; int breatheDir = 1; unsigned long lastBreatheMillis = 0; const int breatheInterval = 10; bool blinkState = false; unsigned long lastBlinkMillis = 0; const int BLINK_INTERVAL_1000MS = 1000; const int BLINK_INTERVAL_500MS = 500; const int BLINK_INTERVAL_200MS = 200; bool doubleBlinkState = false; int doubleBlinkCount = 0; unsigned long lastDoubleBlinkMillis = 0; const int DOUBLE_BLINK_FAST_INTERVAL = 200; const int DOUBLE_BLINK_LONG_ON_INTERVAL = 400; const int DOUBLE_BLINK_PAUSE_INTERVAL = 1000; // Price thresholds for LEDs in EUR/kWh const float PRICE_THRESHOLD_0_05 = 0.05; const float PRICE_THRESHOLD_0_15 = 0.15; const float PRICE_THRESHOLD_0_25 = 0.25; const float PRICE_THRESHOLD_0_35 = 0.35; const float PRICE_THRESHOLD_0_50 = 0.50; // ======================================================================== // SYSTEM VARIABLES // ======================================================================== LiquidCrystal_I2C lcd(0x27, 20, 4); const char* api_url = "https://api.energy-charts.info/price?bzn=SI"; // Timing and fetch control time_t nextScheduledFetchTime = 0; int lastSuccessfulFetchDay = 0; int httpGetRetryCount = 0; const int HTTP_GET_RETRY_MAX = 5; const int HTTP_GET_BACKOFF_FACTOR = 2; time_t lastSuccessfulFetchTime; // V2.4a: API Success tracking for secondary list int apiSuccessCount = 0; int apiFailCount = 0; // Time zone settings for Slovenia (CET/CEST) const long gmtOffset_sec = 3600; const int daylightOffset_sec = 3600; // Power company and VAT fees const bool APPLY_FEES_AND_VAT = true; const float POWER_COMPANY_FEE_PERCENTAGE = 12.0; const float VAT_PERCENTAGE = 22.0; // Button handling with long press for manual refresh int buttonState = 0; int lastButtonState = 0; unsigned long lastDebounceTime = 0; unsigned long buttonPressStartTime = 0; bool longPressDetected = false; const unsigned long debounceDelay = 50; const unsigned long longPressThreshold = 2000; // 2 seconds for long press // V2.0: Double-click detection variables (MINIMAL ADDITION) unsigned long lastClickTime = 0; const unsigned long doubleClickWindow = 500; // 500ms window for double-click bool waitingForDoubleClick = false; bool pendingClick = false; // V2.3: Auto-scroll timeout feature unsigned long lastButtonActivity = 0; const unsigned long autoScrollTimeout = 10000; // 10 seconds timeout bool autoScrollExecuted = false; // FIXED: Flag to prevent repeated auto-scroll // V2.5: Display refresh timing for idle state unsigned long lastHourlyRefresh = 0; // Track last hourly refresh to prevent multiple refreshes per hour unsigned long last15MinRefresh = 0; // Track last 15-minute refresh for updating ">" placeholders // V2.4a: Secondary list scrolling variables int secondaryListOffset = 0; // Tracks current scroll position (increments of 4) const int SECONDARY_LIST_TOTAL_LINES = 16; const int SECONDARY_LIST_SCROLL_INCREMENT = 4; // Backlight control const unsigned long backlightOffDelay = 30000; // 30 seconds unsigned long lastPresenceTime = 0; bool presenceSensorConnected = false; // Loop optimization unsigned long lastLoopUpdate = 0; // Display state management enum DisplayState { CURRENT_PRICES, CUSTOM_MESSAGE, NO_DATA_OFFSET, }; DisplayState displayState = CURRENT_PRICES; int timeOffsetHours = 0; // V2.0: Simple list toggle (MINIMAL ADDITION) enum ListType { PRIMARY_LIST, SECONDARY_LIST }; ListType currentList = PRIMARY_LIST; // Data storage - OPTIMIZED: StaticJsonDocument instead of Dynamic StaticJsonDocument doc; bool isTodayDataAvailable = false; float averagePrice = 0.0; int lowestPriceIndex = -1; int highestPriceIndex = -1; // ======================================================================== // PROVISIONING VARIABLES // ======================================================================== Preferences preferences; // NEW DNSServer dnsServer; // NEW WebServer server(80); // NEW const char* ap_ssid = "MyTicker_Setup"; bool inProvisioningMode = false; bool needsRestart = false; // NEW: Non-blocking NTP sync flag bool isTimeSynced = false; // NEW: Flag to track if this is the initial boot bool initialBoot = true; // ======================================================================== // CUSTOM CHARACTER DEFINITIONS // ======================================================================== byte bitmap_c[8] = { B00100, B00000, B01110, B10001, B10000, B10001, B01110, B00000 }; byte bitmap_s[8] = { B00100, B00000, B01110, B10000, B01110, B00001, B11110, B00000 }; byte bitmap_z[8] = { B00100, B00000, B11111, B00010, B00100, B01000, B11111, B00000 }; byte lo_prc[] = { B00000, B00100, B00100, B00100, B10101, B01110, B00100, B00000 }; byte hi_prc[] = { B00000, B00100, B01110, B10101, B00100, B00100, B00100, B00000 }; // ======================================================================== // UTILITY FUNCTIONS // ======================================================================== // Enhanced debug printing with levels void debugPrint(int level, const String& message) { #if DEBUG_LEVEL >= 1 if (DEBUG_LEVEL >= level) { Serial.println("[DEBUG] " + message); } #endif } // Helper function for special characters void lcdPrint(const char* text) { for (int i = 0; text[i] != '\0'; i++) { char currentChar = text[i]; if (currentChar == '^') { lcd.write(byte(0)); } else if (currentChar == '~') { lcd.write(byte(1)); } else if (currentChar == '|') { lcd.write(byte(2)); } else { lcd.print(currentChar); } } } // Optimized comma printing void commaPrint(float value, int places) { String numStr = String(value, places); numStr.replace('.', ','); lcd.print(numStr); } // Enhanced time validation bool isValidUnixTime(unsigned long timestamp) { return (timestamp > 946684800UL && timestamp < 2147483647UL); // Valid range: 2000-2038 } // ======================================================================== // WIFI PROVISIONING FUNCTIONS (NEW) // ======================================================================== void startProvisioning() { debugPrint(1, "Starting Wi-Fi Provisioning AP"); inProvisioningMode = true; lcd.clear(); lcd.setCursor(0, 0); lcd.print("No Wi-Fi access!"); lcd.setCursor(0, 1); lcd.print("Setup Wi-Fi:"); lcd.setCursor(0, 2); lcd.print("SSID: MyTicker_Setup"); WiFi.mode(WIFI_AP); WiFi.softAP(ap_ssid); IPAddress apIP = WiFi.softAPIP(); dnsServer.start(53, "*", apIP); lcd.setCursor(0, 3); lcd.print("IP: " + apIP.toString()); debugPrint(1, "AP IP address: " + apIP.toString()); server.onNotFound([]() { String html = "

Wi-Fi Setup

SSID:
Password:
"; server.send(200, "text/html", html); }); server.on("/save", HTTP_GET, []() { String newSsid = server.arg("ssid"); String newPass = server.arg("pass"); if (newSsid.length() > 0) { preferences.begin("my-ticker", false); preferences.putString("ssid", newSsid); preferences.putString("pass", newPass); preferences.end(); lcd.clear(); lcd.setCursor(0, 0); lcd.print("Saved!"); lcd.setCursor(0, 1); lcd.print("Restarting..."); server.send(200, "text/html", "Wi-Fi credentials saved. Restarting ESP32..."); needsRestart = true; debugPrint(1, "Credentials saved, restarting."); } else { server.send(200, "text/html", "Invalid credentials. Please go back and try again."); } }); server.begin(); debugPrint(1, "HTTP server started"); } void handleProvisioning() { dnsServer.processNextRequest(); server.handleClient(); } // ======================================================================== // WIFI AND NETWORK FUNCTIONS // ======================================================================== void connectToWiFi() { String stored_ssid = ""; String stored_pass = ""; // Load credentials from NVS preferences.begin("my-ticker", false); stored_ssid = preferences.getString("ssid", ""); stored_pass = preferences.getString("pass", ""); preferences.end(); if (stored_ssid.length() > 0) { lcd.setCursor(0, 0); lcd.print("Elec. Rate SI"); lcd.setCursor(0, 1); lcd.print("Connecting..."); WiFi.begin(stored_ssid.c_str(), stored_pass.c_str()); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < Config::WIFI_RETRY_MAX) { delay(500); lcd.setCursor(12 + (attempts % 8), 1); // Prevent overflow on display lcd.print("."); attempts++; } if (WiFi.status() == WL_CONNECTED) { debugPrint(2, "WiFi connected successfully"); digitalWrite(builtinLedPin, HIGH); lcd.clear(); lcd.setCursor(0, 0); lcd.print("Connected!"); lcd.setCursor(0, 1); lcd.print(WiFi.localIP()); delay(2000); lcd.backlight(); } else { debugPrint(1, "WiFi connection failed after " + String(attempts) + " attempts"); digitalWrite(builtinLedPin, LOW); lcd.clear(); lcd.setCursor(0, 0); lcd.print("WiFi Failed!"); // If connection fails, start provisioning startProvisioning(); } } else { // If no credentials found in NVS, start provisioning startProvisioning(); } } // REMOVED: The blocking waitUntilTimeIsSet() function has been removed. // ======================================================================== // LED CONTROL FUNCTIONS // ======================================================================== void updateLeds() { // MODIFIED: This function now requires isTimeSynced to be true if (!ledsConnected || !areLedsOn || !isTodayDataAvailable || !isTimeSynced) { digitalWrite(whiteLedPin, LOW); breatheValue = 0; breatheDir = 1; blinkState = LOW; doubleBlinkCount = 0; return; } JsonArray prices = doc["price"]; struct tm timeinfo; // MODIFIED: This check is now required here since we don't have a blocking time sync if (!getLocalTime(&timeinfo)) return; int currentHour = timeinfo.tm_hour; // Get current hour (0-23) int quarterHourIndex = getCurrentQuarterHourIndex(); // Get current 15-min interval (0-3) // Calculate the exact 15-minute interval index int currentIntervalIndex = currentHour * 4 + quarterHourIndex; if (currentIntervalIndex >= (int)prices.size()) { digitalWrite(whiteLedPin, LOW); return; } // Get the current 15-minute interval price (not hourly average) float currentRate = prices[currentIntervalIndex].as(); if (currentRate <= 0) { digitalWrite(whiteLedPin, LOW); return; } float finalPrice = currentRate; if (APPLY_FEES_AND_VAT) { finalPrice = finalPrice * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0); } finalPrice /= 1000.0; // Convert to EUR/kWh // LED patterns based on price thresholds if (finalPrice <= PRICE_THRESHOLD_0_05) { // Breathing effect if (millis() - lastBreatheMillis > breatheInterval) { breatheValue += breatheDir; if (breatheValue >= 255 || breatheValue <= 0) { breatheDir *= -1; } analogWrite(whiteLedPin, breatheValue); lastBreatheMillis = millis(); } doubleBlinkCount = 0; } else if (finalPrice <= PRICE_THRESHOLD_0_15) { digitalWrite(whiteLedPin, HIGH); doubleBlinkCount = 0; } else if (finalPrice <= PRICE_THRESHOLD_0_25) { if (millis() - lastBlinkMillis > BLINK_INTERVAL_1000MS) { blinkState = !blinkState; digitalWrite(whiteLedPin, blinkState); lastBlinkMillis = millis(); } doubleBlinkCount = 0; } else if (finalPrice <= PRICE_THRESHOLD_0_35) { if (millis() - lastBlinkMillis > BLINK_INTERVAL_500MS) { blinkState = !blinkState; digitalWrite(whiteLedPin, blinkState); lastBlinkMillis = millis(); } doubleBlinkCount = 0; } else if (finalPrice <= PRICE_THRESHOLD_0_50) { // Double-blinking int targetBlinks = 2; if (doubleBlinkCount < targetBlinks * 2) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) { doubleBlinkState = !doubleBlinkState; digitalWrite(whiteLedPin, doubleBlinkState); lastDoubleBlinkMillis = millis(); doubleBlinkCount++; } } else { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) { doubleBlinkCount = 0; lastDoubleBlinkMillis = millis(); } } } else { // Triple-blinking (short, short, long) if (doubleBlinkCount == 0) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) { digitalWrite(whiteLedPin, HIGH); lastDoubleBlinkMillis = millis(); doubleBlinkCount++; } } else if (doubleBlinkCount == 1) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) { digitalWrite(whiteLedPin, LOW); lastDoubleBlinkMillis = millis(); doubleBlinkCount++; } } else if (doubleBlinkCount == 2) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) { digitalWrite(whiteLedPin, HIGH); lastDoubleBlinkMillis = millis(); doubleBlinkCount++; } } else if (doubleBlinkCount == 3) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) { digitalWrite(whiteLedPin, LOW); lastDoubleBlinkMillis = millis(); doubleBlinkCount++; } } else if (doubleBlinkCount == 4) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) { digitalWrite(whiteLedPin, HIGH); lastDoubleBlinkMillis = millis(); doubleBlinkCount++; } } else if (doubleBlinkCount == 5) { if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_LONG_ON_INTERVAL) { digitalWrite(whiteLedPin, LOW); lastDoubleBlinkMillis = millis(); doubleBlinkCount = 0; } } } } // ======================================================================== // DATA FETCHING AND PROCESSING // ======================================================================== // Helper function to calculate hourly average from 15-minute data float getHourlyAverage(int hourIndex, const JsonArray& prices) { if (hourIndex < 0 || hourIndex >= 24) return 0.0; int startIndex = hourIndex * 4; // 4 values per hour (15-minute intervals) if (startIndex + 3 >= (int)prices.size()) return 0.0; float sum = 0.0; int validCount = 0; for (int i = 0; i < 4; i++) { if (startIndex + i < (int)prices.size()) { sum += prices[startIndex + i].as(); validCount++; } } return validCount > 0 ? sum / validCount : 0.0; } // Helper function to get current 15-minute interval index within the hour int getCurrentQuarterHourIndex() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return 0; int minute = timeinfo.tm_min; return minute / 15; // Returns 0, 1, 2, or 3 } // Helper function to format 15-minute price values in compact format void format15MinPrice(float price, char* buffer, int bufferSize) { if (APPLY_FEES_AND_VAT) { price = price * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0); } price /= 1000.0; // Convert to EUR/kWh // Convert to hundredths (0.1234 becomes 12.34) int hundredths = (int)round(price * 100); if (hundredths > 99) { snprintf(buffer, bufferSize, "+99"); } else if (hundredths < -99) { snprintf(buffer, bufferSize, "-99"); } else if (hundredths >= 0) { snprintf(buffer, bufferSize, " %02d", hundredths); } else { snprintf(buffer, bufferSize, "%03d", hundredths); // Includes the minus sign } } // Helper function to display 15-minute details for current hour void display15MinuteDetails(int row, int hourIndex, const JsonArray& prices) { lcd.setCursor(0, row); if (hourIndex < 0 || hourIndex >= 24) { lcd.print(" "); return; } // Enhanced wraparound detection (same logic as displayPriceRow) struct tm timeinfo; int currentHour = -1; int currentMinute = -1; bool hasValidTime = false; if (getLocalTime(&timeinfo)) { currentHour = timeinfo.tm_hour; currentMinute = timeinfo.tm_min; hasValidTime = true; // Case 1: Early hours (0-5) shown when we're in evening (>= 18) - likely wraparound if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) { lcd.print(" "); return; } // Case 2: General past hours (except near midnight transitions) if (hourIndex < currentHour && currentHour < 22) { lcd.print(" "); return; } } int startIndex = hourIndex * 4; char priceBuffer[4]; int cursorPos = 0; // Calculate current 15-minute segment (0-3) for the displayed hour int currentSegment = -1; if (hasValidTime && hourIndex == currentHour) { currentSegment = currentMinute / 15; // 0-14min=0, 15-29min=1, 30-44min=2, 45-59min=3 } for (int i = 0; i < 4; i++) { bool shouldShowPlaceholder = false; // Show placeholder for past segments if this is the current hour if (hasValidTime && hourIndex == currentHour && i < currentSegment) { shouldShowPlaceholder = true; } if (shouldShowPlaceholder) { // Show placeholder for past 15-minute segments lcd.print(" > "); cursorPos += 3; } else if (startIndex + i < (int)prices.size()) { // Show actual price for current and future segments float price = prices[startIndex + i].as(); format15MinPrice(price, priceBuffer, sizeof(priceBuffer)); lcd.print(priceBuffer); cursorPos += 3; } else { // No data available lcd.print("---"); cursorPos += 3; } if (i < 3) { lcd.print(" "); // Add spacing between values cursorPos += 2; } } // Fill remaining space (20 - cursorPos) for (int i = cursorPos; i < 20; i++) { lcd.print(" "); } } void fetchAndProcessData() { // MODIFIED: This function now requires isTimeSynced to be true if (WiFi.status() != WL_CONNECTED || !isTimeSynced) { debugPrint(1, "Not connected to WiFi or time not synced."); apiFailCount++; // V2.4a: Track failures return; } HTTPClient http; http.begin(api_url); http.setTimeout(Config::HTTP_TIMEOUT); http.setConnectTimeout(Config::HTTP_CONNECT_TIMEOUT); int httpResponseCode = http.GET(); if (httpResponseCode > 0) { debugPrint(2, "HTTP Response: " + String(httpResponseCode)); // 💡 OPTIMIZED: Deserialize directly from the HTTP stream to save RAM doc.clear(); DeserializationError error = deserializeJson(doc, http.getStream()); if (error) { debugPrint(1, "deserializeJson() failed: " + String(error.c_str())); apiFailCount++; // V5.0 FIX: Force display update on JSON failure displayPrices(); http.end(); return; } // Success - update counters and timestamps apiSuccessCount++; time_t now; time(&now); lastSuccessfulFetchTime = now; // Process and validate data processJsonData(); httpGetRetryCount = 0; debugPrint(2, "Data fetched and processed successfully"); // V5.0 FIX: Force an immediate display update after the entire process. displayPrices(); // V5.3 QUICK RE-SCHEDULE: If fetch succeeds but the data is marked as stale, // schedule the next fetch in 10 minutes (600 seconds). if (!isTodayDataAvailable) { time_t now; time(&now); nextScheduledFetchTime = now + 600; debugPrint(2, "Data is stale. Next fetch aggressively scheduled in 10 minutes."); } } else { debugPrint(1, "HTTP request failed with code: " + String(httpResponseCode)); apiFailCount++; httpGetRetryCount++; // V5.0 FIX: Force display update on HTTP failure displayPrices(); // *** MODIFICATION START - V5.4: Aggressive Fetch Retry on Failure *** // If HTTP failed, schedule an aggressive retry in 5 minutes (300 seconds) time_t now; time(&now); nextScheduledFetchTime = now + 300; debugPrint(2, "HTTP failed. Next fetch aggressively scheduled in 5 minutes."); // *** MODIFICATION END *** } http.end(); } void processJsonData() { JsonArray prices = doc["price"]; JsonArray unixSeconds = doc["unix_seconds"]; if (prices.size() == 0 || unixSeconds.size() == 0) { debugPrint(1, "No price data in API response"); isTodayDataAvailable = false; return; } // 💡 CORRECTED LOGIC: Check if the API data is for the current day unsigned long firstUnixTime = unixSeconds[0].as(); time_t firstDataTime = (time_t)firstUnixTime; struct tm* timeinfo_ptr = localtime(&firstDataTime); struct tm currentTimeinfo; if (!getLocalTime(¤tTimeinfo)) { debugPrint(1, "Could not get current time for data validation."); isTodayDataAvailable = false; return; } if (timeinfo_ptr != NULL && timeinfo_ptr->tm_mday != currentTimeinfo.tm_mday) { debugPrint(1, "Fetched data is not for the current day. Ignoring."); isTodayDataAvailable = false; // V5.2 FIX: Reset the display state to force the NO_DATA_OFFSET screen. if (displayState != NO_DATA_OFFSET) { displayState = CURRENT_PRICES; } return; } // Data is for today, proceed with processing isTodayDataAvailable = true; // Calculate daily average and find min/max using hourly averages float sum = 0; float minPrice = 999999; // Initialize with large value float maxPrice = -999999; // Initialize with small value lowestPriceIndex = 0; highestPriceIndex = 0; // Calculate hourly averages and find min/max among them for (int hour = 0; hour < 24; hour++) { float hourlyAvg = getHourlyAverage(hour, prices); if (hourlyAvg > 0) { // Only consider valid hourly averages sum += hourlyAvg; if (hourlyAvg < minPrice) { minPrice = hourlyAvg; lowestPriceIndex = hour * 4; // Convert back to 15-minute index for display } if (hourlyAvg > maxPrice) { maxPrice = hourlyAvg; highestPriceIndex = hour * 4; // Convert back to 15-minute index for display } } } averagePrice = sum / 24; // Average of 24 hourly averages debugPrint(3, "Processed " + String(prices.size()) + " price entries (15-min intervals)"); debugPrint(3, "Daily average: " + String(averagePrice) + " EUR/MWh"); } void displayPriceRow(int row, int hourIndex, const JsonArray& prices, const JsonArray& unixSeconds) { lcd.setCursor(0, row); // Enhanced wraparound detection to prevent showing early hours after late hours struct tm timeinfo; if (getLocalTime(&timeinfo)) { int currentHour = timeinfo.tm_hour; // Case 1: Early hours (0-5) shown when we're in evening (>= 18) - likely wraparound if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) { lcd.print(" "); return; } // Case 2: General past hours (except near midnight transitions) if (hourIndex < currentHour && currentHour < 22) { lcd.print(" "); return; } } if (hourIndex >= 0 && hourIndex < 24) { // Calculate hourly average from 15-minute data float rates = getHourlyAverage(hourIndex, prices); // Use the first 15-minute timestamp of the hour for time display int dataIndex = hourIndex * 4; if (dataIndex < (int)unixSeconds.size()) { unsigned long hourlyUnixTime = unixSeconds[dataIndex].as(); if (isValidUnixTime(hourlyUnixTime)) { time_t t = (time_t)hourlyUnixTime; struct tm *time_ptr = localtime(&t); if (time_ptr != NULL) { char buffer[21]; // 💡 OPTIMIZED: Use a buffer for formatting int hour = time_ptr->tm_hour; int minute = 0; // Always show full hour (00 minutes) snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute); //snprintf(buffer, sizeof(buffer), "%2dh", hour); lcd.print(buffer); // Price indicators based on hourly averages if (dataIndex == lowestPriceIndex) { lcd.setCursor(7, row); lcd.write(byte(3)); // Low price indicator lcd.print(" "); } else if (dataIndex == highestPriceIndex) { lcd.setCursor(7, row); lcd.write(byte(4)); // High price indicator lcd.print(" "); } else { lcd.setCursor(6, row); lcd.print(" "); } float finalPrice = rates; if (APPLY_FEES_AND_VAT) { finalPrice = finalPrice * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0); } finalPrice /= 1000.0; if (finalPrice < 0) { lcd.setCursor(9, row); } else { lcd.setCursor(10, row); } commaPrint(finalPrice, 4); lcd.print(" EUR"); } else { lcd.print("Time Error "); } } else { lcd.print("Invalid Time "); } } else { lcd.print("No Data Available "); } } else { lcd.print(" "); } } void displayPrimaryList() { JsonArray prices = doc["price"]; JsonArray unixSeconds = doc["unix_seconds"]; if (!isTodayDataAvailable) { for (int i = 0; i < 4; i++) { lcd.setCursor(0, i); lcd.print("No data available "); } return; } // Find current hour base index int baseHour = 0; struct tm timeinfo; if (getLocalTime(&timeinfo)) { baseHour = timeinfo.tm_hour; // Always use current hour as base } // Calculate display hours with smart end-of-day handling int displayStartHour = baseHour + timeOffsetHours; // Special end-of-day adjustment: if we're at hour 21-23, modify the base for scrolling if (baseHour >= 21 && timeOffsetHours > 0) { // Allow seeing hours 21, 22, 23 by adjusting the base int adjustedBase = 21; displayStartHour = adjustedBase + timeOffsetHours; } // Handle day wraparound if (displayStartHour >= 24) { displayStartHour = displayStartHour % 24; } // Row 0: 15-minute details for current hour (MOVED TO TOP) display15MinuteDetails(0, displayStartHour, prices); // Row 1: Current hour average (MOVED TO SECOND) displayPriceRow(1, displayStartHour, prices, unixSeconds); // Row 2: Next hour average int nextHour = (displayStartHour + 1) % 24; displayPriceRow(2, nextHour, prices, unixSeconds); // Row 3: Hour after that int hourAfter = (displayStartHour + 2) % 24; displayPriceRow(3, hourAfter, prices, unixSeconds); } // V2.4a: Enhanced secondary list with 16 scrollable lines void displaySecondaryList() { // Generate all 16 lines of content char lines[SECONDARY_LIST_TOTAL_LINES][21]; // 💡 OPTIMIZED: Use char arrays // Line 0: Current Date & Time (HH:MM DD.MM.YYYY) struct tm timeinfo; if (getLocalTime(&timeinfo)) { // V5.2 FIX: Reduced spaces from 6 to 3 to ensure fit: HH:MM DD.MM.YYYY (5 + 3 + 10 = 18 chars) snprintf(lines[0], sizeof(lines[0]), "%d:%02d %d.%d.%04d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_mday, timeinfo.tm_mon + 1, timeinfo.tm_year + 1900); } else { snprintf(lines[0], sizeof(lines[0]), "Time not available "); } // Line 1: Separator snprintf(lines[1], sizeof(lines[1]), "--------------------"); // Line 2: "Zadnja posodobitev:" header snprintf(lines[2], sizeof(lines[2]), "Zadnja posodobitev:"); // Line 3: Timestamp (DD.MM.YYYY ob HH:MM) struct tm* timeinfo_ptr = localtime(&lastSuccessfulFetchTime); if (timeinfo_ptr != NULL) { snprintf(lines[3], sizeof(lines[3]), "%d.%d.%04d ob %2d:%02d", timeinfo_ptr->tm_mday, timeinfo_ptr->tm_mon + 1, timeinfo_ptr->tm_year + 1900, timeinfo_ptr->tm_hour, timeinfo_ptr->tm_min); } else { snprintf(lines[3], sizeof(lines[3]), "No data timestamp "); } // Line 4: Empty separator snprintf(lines[4], sizeof(lines[4]), ""); // Line 5: "Dnevno povpre~je:" header snprintf(lines[5], sizeof(lines[5]), "Dnevno povpre^je:"); // Line 6: Daily average value OR "Cene niso na voljo" if (isTodayDataAvailable) { float finalPrice = averagePrice; if (APPLY_FEES_AND_VAT) { finalPrice = finalPrice * (1 + POWER_COMPANY_FEE_PERCENTAGE / 100.0) * (1 + VAT_PERCENTAGE / 100.0); } char priceBuffer[21]; snprintf(priceBuffer, sizeof(priceBuffer), "%.4f EUR/kWh", finalPrice / 1000.0); String numStr = String(priceBuffer); numStr.replace('.', ','); snprintf(lines[6], sizeof(lines[6]), numStr.c_str()); } else { snprintf(lines[6], sizeof(lines[6]), "Cene niso na voljo."); } // Line 7: Empty separator snprintf(lines[7], sizeof(lines[7]), ""); // Line 8: WiFi signal strength if (WiFi.status() == WL_CONNECTED) { snprintf(lines[8], sizeof(lines[8]), "WiFi:%5d dBm", WiFi.RSSI()); } else { snprintf(lines[8], sizeof(lines[8]), "WiFi: Disconnected"); } // Line 9: IP Address if (WiFi.status() == WL_CONNECTED) { snprintf(lines[9], sizeof(lines[9]), "IP:%15s", WiFi.localIP().toString().c_str()); } else { snprintf(lines[9], sizeof(lines[9]), "IP: Not connected"); } // Line 10: API Success Rate int totalApiCalls = apiSuccessCount + apiFailCount; if (totalApiCalls > 0) { int successRate = (apiSuccessCount * 100) / totalApiCalls; snprintf(lines[10], sizeof(lines[10]), "API: %d%% (%d/%d)", successRate, apiSuccessCount, totalApiCalls); } else { snprintf(lines[10], sizeof(lines[10]), "API: No calls yet"); } // Line 11: System uptime unsigned long uptimeMs = millis(); unsigned long days = uptimeMs / (24 * 60 * 60 * 1000); uptimeMs %= (24 * 60 * 60 * 1000); unsigned long hours = uptimeMs / (60 * 60 * 1000); uptimeMs %= (60 * 60 * 1000); unsigned long minutes = uptimeMs / (60 * 1000); snprintf(lines[11], sizeof(lines[11]), "Up: %ldd %ldh %ldm", days, hours, minutes); // Lines 12-15: Static credits snprintf(lines[12], sizeof(lines[12]), "energy-charts.info"); snprintf(lines[13], sizeof(lines[13]), "dynamic electricity"); snprintf(lines[14], sizeof(lines[14]), "price ticker v5.5"); snprintf(lines[15], sizeof(lines[15]), "by Amir Toki^ (2025)"); // Display current 4-line window based on scroll offset for (int i = 0; i < 4; i++) { int lineIndex = secondaryListOffset + i; lcd.setCursor(0, i); if (lineIndex < SECONDARY_LIST_TOTAL_LINES) { lcdPrint(lines[lineIndex]); // Clear remainder for (int j = strlen(lines[lineIndex]); j < 20; j++) { lcd.print(" "); } } else { // Clear empty lines lcd.print(" "); } } } void displayPrices() { lcd.clear(); // MODIFIED: This check has been moved to the start of the function if (!isTimeSynced) { lcd.setCursor(0, 0); lcd.print("Syncing Time..."); lcd.setCursor(0, 1); lcd.print("Please wait..."); return; } if (currentList == SECONDARY_LIST) { displaySecondaryList(); return; } // *** MODIFICATION START - V5.4 CORE FIX: Absolute State Enforcement *** if (!isTodayDataAvailable) { // If data is unavailable (LED off), absolutely force the NO_DATA_OFFSET state. // This resolves the screen/LED desync and ensures stale data is not displayed. displayState = NO_DATA_OFFSET; timeOffsetHours = 0; // Reset scroll position too } else if (displayState == NO_DATA_OFFSET) { // If data IS available now, and we were stuck on the NO_DATA screen, reset to CURRENT_PRICES. displayState = CURRENT_PRICES; timeOffsetHours = 0; } // *** MODIFICATION END *** switch (displayState) { case NO_DATA_OFFSET: lcd.setCursor(0, 0); lcd.print("No data for today"); lcd.setCursor(0, 1); lcd.print("Press & hold to"); lcd.setCursor(0, 2); lcd.print("refresh manually"); break; case CURRENT_PRICES: displayPrimaryList(); break; case CUSTOM_MESSAGE: displayState = CURRENT_PRICES; displayPrimaryList(); break; } // NEW: Reset the initialBoot flag after the first successful display update. initialBoot = false; } void resetDisplayToTop() { // Always reset to show current hour (offset 0) timeOffsetHours = 0; secondaryListOffset = 0; displayState = CURRENT_PRICES; currentList = PRIMARY_LIST; debugPrint(2, "Auto-scroll timeout - Reset display to current hour"); displayPrices(); } void advanceDisplayOffset() { // MODIFIED: This function now requires isTimeSynced to be true if (!isTimeSynced) return; if (currentList == SECONDARY_LIST) { secondaryListOffset += SECONDARY_LIST_SCROLL_INCREMENT; if (secondaryListOffset >= SECONDARY_LIST_TOTAL_LINES) { secondaryListOffset = 0; } displayPrices(); return; } if (!isTodayDataAvailable) { displayState = NO_DATA_OFFSET; displayPrices(); return; } if (displayState == NO_DATA_OFFSET) { return; } else { // Get current hour for scrolling logic struct tm timeinfo; int currentHour = 0; if (getLocalTime(&timeinfo)) { currentHour = timeinfo.tm_hour; } // Calculate maximum offset with minimum scrollability at end of day int maxOffset = 23 - currentHour; // Ensure minimum scrollability at end of day (can see at least 2 previous hours) if (maxOffset < 2 && currentHour >= 21) { maxOffset = 2; } // Advance by 1 hour, but don't exceed the maximum int nextOffsetHours = timeOffsetHours + 1; if (nextOffsetHours > maxOffset) { // Reset to beginning (current hour) timeOffsetHours = 0; displayState = CURRENT_PRICES; } else { timeOffsetHours = nextOffsetHours; } } displayPrices(); } void toggleList() { // MODIFIED: This function now requires isTimeSynced to be true if (!isTimeSynced) return; currentList = (currentList == PRIMARY_LIST) ? SECONDARY_LIST : PRIMARY_LIST; debugPrint(2, "Toggled to " + String(currentList == PRIMARY_LIST ? "PRIMARY" : "SECONDARY") + " list"); displayPrices(); } void handleButton() { // MODIFIED: Button presses are now handled even if time isn't synced int reading = !digitalRead(buttonPin); if (reading != lastButtonState) { lastDebounceTime = millis(); if (reading == LOW) { buttonPressStartTime = millis(); longPressDetected = false; } } if ((millis() - lastDebounceTime) > debounceDelay) { if (reading != buttonState) { buttonState = reading; if (buttonState == HIGH) { unsigned long pressDuration = millis() - buttonPressStartTime; if (longPressDetected) { debugPrint(1, "Long press detected - Forcing manual data refresh"); lastButtonActivity = millis(); autoScrollExecuted = false; // Clear screen and show message immediately lcd.clear(); lcd.setCursor(0, 0); lcd.print("Manual Refresh..."); lcd.setCursor(0, 1); lcd.print("Please wait..."); // Force the fetch immediately time_t now; time(&now); nextScheduledFetchTime = now; } else if (pressDuration < longPressThreshold) { unsigned long currentTime = millis(); if (waitingForDoubleClick && (currentTime - lastClickTime <= doubleClickWindow)) { waitingForDoubleClick = false; pendingClick = false; lastButtonActivity = millis(); autoScrollExecuted = false; debugPrint(2, "Double-click detected - toggling list"); toggleList(); } else { lastClickTime = currentTime; waitingForDoubleClick = true; pendingClick = true; } } } } if (buttonState == LOW && !longPressDetected) { if (millis() - buttonPressStartTime >= longPressThreshold) { longPressDetected = true; lcd.clear(); lcd.setCursor(0, 0); lcd.print("Long press detected!"); lcd.setCursor(0, 1); lcd.print("Release to refresh"); debugPrint(2, "Long press threshold reached - waiting for release"); } } } if (waitingForDoubleClick && pendingClick && (millis() - lastClickTime > doubleClickWindow)) { waitingForDoubleClick = false; pendingClick = false; lastButtonActivity = millis(); autoScrollExecuted = false; debugPrint(3, "Single click confirmed - advancing display"); advanceDisplayOffset(); } lastButtonState = reading; } void handleBacklight() { if (!presenceSensorConnected) { lcd.backlight(); areLedsOn = true; lastPresenceTime = millis(); return; } bool isPresent = digitalRead(presencePin) == HIGH; if (isPresent) { lastPresenceTime = millis(); lcd.backlight(); areLedsOn = true; } else { if (millis() - lastPresenceTime >= backlightOffDelay) { lcd.noBacklight(); areLedsOn = false; } else { lcd.backlight(); areLedsOn = true; } } } void handlePresenceSensor() { static bool lastPresenceState = false; bool currentPresenceState = digitalRead(presencePin) == HIGH; if (currentPresenceState != lastPresenceState) { if (currentPresenceState) { debugPrint(3, "Presence detected - turning on backlight"); lcd.backlight(); lastPresenceTime = millis(); } lastPresenceState = currentPresenceState; } } // ======================================================================== // SCHEDULING AND DATA FETCHING // ======================================================================== void handleDataFetching() { // MODIFIED: Now requires isTimeSynced to be true to run if (!isTimeSynced) { return; } time_t now; time(&now); if (now >= nextScheduledFetchTime) { debugPrint(2, "Scheduled data fetch triggered"); fetchAndProcessData(); struct tm* timeinfo_ptr = localtime(&now); if (timeinfo_ptr != NULL) { lastSuccessfulFetchDay = timeinfo_ptr->tm_mday; // Only reschedule to the top of the next hour if we aren't already aggressively retrying // Aggressive retries are 300s (HTTP failed) or 600s (Stale data) if (nextScheduledFetchTime < now + 300) { time_t nextHour = now - (timeinfo_ptr->tm_min * 60) - timeinfo_ptr->tm_sec + 3600; nextScheduledFetchTime = nextHour; debugPrint(2, "Next fetch scheduled for the top of the next hour."); } } } } // ======================================================================== // SETUP AND MAIN LOOP // ======================================================================== void setup() { Serial.begin(115200); debugPrint(2, "Starting Dynamic Electricity Ticker v5.5 (15-MINUTE DETAIL MODE)"); pinMode(builtinLedPin, OUTPUT); pinMode(whiteLedPin, OUTPUT); pinMode(buttonPin, INPUT_PULLUP); const int NUM_DETECTION_SAMPLES = 5; int high_reads = 0; pinMode(presencePin, INPUT); delay(50); for (int i = 0; i < NUM_DETECTION_SAMPLES; i++) { if (digitalRead(presencePin) == HIGH) { high_reads++; } delay(5); } presenceSensorConnected = (high_reads > 0); if (presenceSensorConnected) { debugPrint(2, "Presence sensor detected and connected"); } else { debugPrint(2, "Presence sensor not detected - backlight always on"); } lcd.init(); lcd.backlight(); lastPresenceTime = millis(); lcd.createChar(0, bitmap_c); lcd.createChar(1, bitmap_s); lcd.createChar(2, bitmap_z); lcd.createChar(3, lo_prc); lcd.createChar(4, hi_prc); lcd.setCursor(0, 0); lcd.print("Initializing..."); delay(1000); // MODIFIED: connectToWiFi() no longer just connects, it also provisions connectToWiFi(); if (WiFi.status() == WL_CONNECTED) { // MODIFIED: Time sync is now non-blocking configTime(gmtOffset_sec, daylightOffset_sec, "pool.ntp.org"); // REMOVED: No more blocking `waitUntilTimeIsSet()` call } debugPrint(2, "Setup completed successfully"); } void loop() { if (needsRestart) { delay(100); ESP.restart(); } if (inProvisioningMode) { handleProvisioning(); return; // Do not run main ticker loop logic } // NEW: Non-blocking time sync check if (!isTimeSynced) { struct tm timeinfo; if (getLocalTime(&timeinfo)) { debugPrint(2, "NTP synchronization successful"); isTimeSynced = true; lastButtonActivity = millis(); autoScrollExecuted = false; // First fetch after time sync time_t now; time(&now); nextScheduledFetchTime = now; displayPrices(); // Update display to show real data } else { // Display syncing message while we wait for time lcd.setCursor(0, 0); lcd.print("Connecting..."); lcd.setCursor(0, 1); lcd.print("Syncing Time..."); return; // Skip the rest of the loop until time is synced } } handleButton(); handlePresenceSensor(); handleBacklight(); updateLeds(); handleDataFetching(); if (!autoScrollExecuted && millis() - lastButtonActivity >= autoScrollTimeout) { resetDisplayToTop(); autoScrollExecuted = true; } time_t now = time(nullptr); struct tm* timeinfo = localtime(&now); if (timeinfo != nullptr) { unsigned long currentHour = timeinfo->tm_hour; unsigned long currentMinute = timeinfo->tm_min; // Hourly refresh at top of hour if (currentMinute == 0 && lastHourlyRefresh != currentHour) { debugPrint(2, "Top of hour refresh - Hour: " + String(currentHour)); displayPrices(); lastHourlyRefresh = currentHour; } // 15-minute refresh for updating ">" placeholders (at minutes 0, 15, 30, 45) unsigned long current15MinBlock = (currentHour * 4) + (currentMinute / 15); if ((currentMinute % 15 == 0) && last15MinRefresh != current15MinBlock) { debugPrint(2, "15-minute refresh - updating placeholders"); displayPrices(); last15MinRefresh = current15MinBlock; } } if (millis() - lastLoopUpdate >= Config::LOOP_UPDATE_INTERVAL) { lastLoopUpdate = millis(); } }