mirror of
https://github.com/Legolas-2025/Standalone-electricity-price-ticker.git
synced 2026-08-18 12:44:54 +02:00
Update to v6.2.2 - Screen blank lines issue fix after v6.2.0 DST Fix - Problem: Sometimes rows 0 and 1 (current 15-min prices and current hour) were blank - Cause: The "hour suppression" logic was hiding the current hour unexpectedly - Fix: Row 1 (current hour) now ALWAYS shows - suppression logic only applies to rows 2-3 - Row 0 (15-min details) also always shows for the current hour
1829 lines
61 KiB
Arduino
1829 lines
61 KiB
Arduino
/*
|
||
ESP32_standalone_electricity_ticker_6_2_2.ino
|
||
-----------------------------------------------------
|
||
|
||
VERSION 6.2.2 CHANGES (2026-03-31):
|
||
----------------------------------
|
||
|
||
FIX: Display blank lines issue
|
||
- Problem: Sometimes rows 0 and 1 (current 15-min prices and current hour) were blank
|
||
- Cause: The "hour suppression" logic was hiding the current hour unexpectedly
|
||
- Fix: Row 1 (current hour) now ALWAYS shows - suppression logic only applies to rows 2-3
|
||
- Row 0 (15-min details) also always shows for the current hour
|
||
|
||
VERSION 6.2.1 CHANGES (2026-03-30):
|
||
----------------------------------
|
||
|
||
BUG FIX: findCurrentPriceIndex() now returns correct current interval
|
||
- Problem: The function was finding the NEXT 15-minute interval (first >= now)
|
||
instead of the CURRENT interval (the one we're IN).
|
||
- At 17:57, it returned index for 18:00 instead of 17:45
|
||
- Result: Display showed hour 18 instead of hour 17
|
||
- Fix: Now calculates next 15-minute boundary and finds the last entry < that boundary
|
||
|
||
VERSION 6.2.0 CHANGES (2026-03-29):
|
||
----------------------------------
|
||
DST FIX:
|
||
- CRITICAL: Fixed DST (Daylight Saving Time) handling on switch days.
|
||
- Previously, the code assumed every day has exactly 24 hours × 4 quarters = 96 price entries.
|
||
This caused incorrect display on DST days:
|
||
- Spring forward (March): Only 23 hours exist → 92 entries
|
||
- Fall back (October): 25 hours exist → 100 entries
|
||
- FIX: All price lookups now use timestamp-based searching through unix_seconds array
|
||
instead of arithmetic calculation (hourIndex * 4).
|
||
- New functions added: findPriceIndexForHour(), findCurrentPriceIndex(), getHourFromPriceIndex()
|
||
- Updated functions: getHourlyAverage(), display15MinuteDetails(), displayPriceRow(),
|
||
displayPrimaryList(), updateLeds()
|
||
- This makes the ticker work correctly on all days, including DST switch days.
|
||
|
||
VERSION 6.1.2 CHANGES (2026-03-11):
|
||
----------------------------------
|
||
FIX:
|
||
- Restore correct white LED price indicator behavior on ESP32 (XIAO ESP32C3) by
|
||
avoiding mixing analogWrite() (LEDC PWM) and digitalWrite() on the same pin.
|
||
- When LED should be OFF we now use analogWrite(whiteLedPin, 0) (instead of digitalWrite LOW).
|
||
- When LED should be fully ON we now use analogWrite(whiteLedPin, 255) (instead of digitalWrite HIGH).
|
||
- Blink / double-blink patterns now toggle between PWM 0 and 255 so the LED is truly off
|
||
when backlight/presence logic turns LEDs off (no more dimly-lit LED).
|
||
*/
|
||
|
||
// ========================================================================
|
||
// Dynamic Electricity Ticker for XIAO ESP32C3 - VERSION 6.2.2
|
||
// 15-MINUTE DETAIL MODE, DST-SAFE via timestamp-based lookups (CET <-> CEST automatic)
|
||
// ========================================================================
|
||
|
||
#include <Wire.h>
|
||
#include <LiquidCrystal_I2C.h>
|
||
#include <WiFi.h>
|
||
#include <HTTPClient.h>
|
||
#include <ArduinoJson.h>
|
||
#include <math.h>
|
||
#include <Preferences.h> // For non-volatile storage
|
||
#include <DNSServer.h> // For captive portal
|
||
#include <WebServer.h> // For provisioning web server
|
||
#include <time.h>
|
||
|
||
// ========================================================================
|
||
// CONFIG STRUCT
|
||
// ========================================================================
|
||
|
||
struct Config {
|
||
static const int JSON_BUFFER_SIZE = 4096;
|
||
static const int HTTP_TIMEOUT = 10000;
|
||
static const int HTTP_CONNECT_TIMEOUT = 5000;
|
||
static const int WIFI_RETRY_MAX = 20;
|
||
static const int NTP_TIMEOUT = 15000;
|
||
static const int LOOP_UPDATE_INTERVAL = 100;
|
||
};
|
||
|
||
#define DEBUG_LEVEL 2
|
||
#define HAS_WHITE_LED true
|
||
|
||
// ========================================================================
|
||
// GLOBALS
|
||
// ========================================================================
|
||
|
||
const int whiteLedPin = 5;
|
||
const int builtinLedPin = 21;
|
||
const int buttonPin = 4;
|
||
const int presencePin = 9;
|
||
|
||
bool ledsConnected = HAS_WHITE_LED;
|
||
bool areLedsOn = false;
|
||
|
||
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;
|
||
|
||
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;
|
||
|
||
LiquidCrystal_I2C lcd(0x27, 20, 4);
|
||
|
||
const char* api_url = "https://api.energy-charts.info/price?bzn=SI";
|
||
|
||
// Scheduling & retry
|
||
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 = 0;
|
||
|
||
int apiSuccessCount = 0;
|
||
int apiFailCount = 0;
|
||
|
||
// Timezone
|
||
// Note: This firmware uses timestamp-based price lookups that work correctly on ALL days
|
||
// (including DST switch days with 23 or 25 hours). The TZ string only affects how
|
||
// local time is displayed and interpreted.
|
||
//
|
||
// To DISABLE DST switching and stay on ONE time zone year-round:
|
||
// ---------------------------------------------------------------------------
|
||
// Option A - Stay on CET (UTC+1, winter time) permanently:
|
||
// const char* TZ_CET_CEST = "CET-1"; // Always UTC+1
|
||
//
|
||
// Option B - Stay on CEST (UTC+2, summer time) permanently:
|
||
// const char* TZ_CET_CEST = "CEST-2"; // Always UTC+2
|
||
//
|
||
// Option C - Use fixed offset without timezone name:
|
||
// const char* TZ_CET_CEST = "UTC+1"; // Always UTC+1
|
||
// const char* TZ_CET_CEST = "UTC+2"; // Always UTC+2
|
||
//
|
||
// Option D - For other countries (e.g., Germany/Austria/Switzerland):
|
||
// Germany (CET permanent): const char* TZ_CET_CEST = "CET-1";
|
||
// Germany (CEST permanent): const char* TZ_CET_CEST = "CEST-2";
|
||
//
|
||
// The current default "CET-1CEST,M3.5.0/02:00,M10.5.0/03:00" switches automatically:
|
||
// - Spring forward: Last Sunday of March at 02:00 → 03:00 (CEST, UTC+2)
|
||
// - Fall back: Last Sunday of October at 03:00 → 02:00 (CET, UTC+1)
|
||
const long gmtOffset_sec = 3600;
|
||
const int daylightOffset_sec = 3600;
|
||
const char* TZ_CET_CEST = "CET-1CEST,M3.5.0/02:00,M10.5.0/03:00";
|
||
|
||
// Price computation
|
||
const bool APPLY_FEES_AND_VAT = true;
|
||
const float POWER_COMPANY_FEE_PERCENTAGE = 12.0;
|
||
const float VAT_PERCENTAGE = 22.0;
|
||
|
||
// Button handling
|
||
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 = 3000;
|
||
|
||
unsigned long lastClickTime = 0;
|
||
const unsigned long doubleClickWindow = 500;
|
||
bool waitingForDoubleClick = false;
|
||
bool pendingClick = false;
|
||
|
||
// Auto scroll timeout
|
||
unsigned long lastButtonActivity = 0;
|
||
const unsigned long autoScrollTimeout = 10000;
|
||
bool autoScrollExecuted = false;
|
||
|
||
// Time-based display refresh markers
|
||
unsigned long lastHourlyRefresh = 0;
|
||
unsigned long last15MinRefresh = 0;
|
||
|
||
// Secondary list / menu
|
||
int secondaryListOffset = 0;
|
||
// INCREASED: now 20 lines total (16 old + 4 NVS status)
|
||
const int SECONDARY_LIST_TOTAL_LINES = 20;
|
||
const int SECONDARY_LIST_SCROLL_INCREMENT = 4;
|
||
|
||
// Backlight / presence
|
||
const unsigned long backlightOffDelay = 30000;
|
||
unsigned long lastPresenceTime = 0;
|
||
bool presenceSensorConnected = false;
|
||
|
||
// Loop pacing
|
||
unsigned long lastLoopUpdate = 0;
|
||
|
||
// Display state
|
||
enum DisplayState { CURRENT_PRICES, CUSTOM_MESSAGE, NO_DATA_OFFSET };
|
||
DisplayState displayState = CURRENT_PRICES;
|
||
int timeOffsetHours = 0;
|
||
|
||
enum ListType { PRIMARY_LIST, SECONDARY_LIST };
|
||
ListType currentList = PRIMARY_LIST;
|
||
|
||
// JSON doc and data flags
|
||
StaticJsonDocument<Config::JSON_BUFFER_SIZE> doc;
|
||
bool isTodayDataAvailable = false;
|
||
float averagePrice = 0.0;
|
||
int lowestPriceIndex = -1;
|
||
int highestPriceIndex = -1;
|
||
|
||
// NVS and provisioning
|
||
Preferences preferences;
|
||
DNSServer dnsServer;
|
||
WebServer server(80);
|
||
|
||
const char* ap_ssid = "MyTicker_Setup";
|
||
bool inProvisioningMode = false;
|
||
bool needsRestart = false;
|
||
|
||
// Time sync flag
|
||
bool isTimeSynced = false;
|
||
bool initialBoot = true;
|
||
|
||
// Track the current local day so we can detect midnight rollover
|
||
int trackedDay = -1;
|
||
|
||
// NVS-related state (for secondary menu / debug)
|
||
bool nvsDataLoadedForToday = false;
|
||
bool nvsDataPresent = false;
|
||
int nvsStoredDay = -1;
|
||
int nvsStoredMonth = -1;
|
||
int nvsStoredYear = -1;
|
||
time_t nvsLastStoreTime = 0;
|
||
|
||
// Midnight retry tracking
|
||
int midnightRetryCount = 0; // Number of 10-minute retries attempted since midnight (max 5)
|
||
bool midnightPhaseActive = false; // true from midnight rollover until successful fetch for that day
|
||
|
||
// NEW: track whether last processJsonData() accepted dataset as "today"
|
||
bool lastProcessJsonAcceptedToday = false;
|
||
|
||
// ========================================================================
|
||
// CUSTOM CHARACTER BITMAPS
|
||
// ========================================================================
|
||
|
||
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 };
|
||
|
||
// Forward declarations
|
||
int getCurrentQuarterHourIndex();
|
||
void displayPrices();
|
||
bool processJsonData(); // NOTE: now returns bool
|
||
void scheduleAfterMidnightFailure();
|
||
|
||
// ========================================================================
|
||
// TIMESTAMP-BASED INDEX LOOKUP (DST-SAFE)
|
||
// ========================================================================
|
||
|
||
// Find the price index that corresponds to a given local Unix timestamp.
|
||
// Searches unixSeconds array for the first entry whose hour matches the
|
||
// given timestamp's hour. This works correctly on DST days because it
|
||
// uses actual Unix timestamps rather than assuming 24 hours * 4 = 96 entries.
|
||
// Returns the index into the prices/unixSeconds arrays, or -1 if not found.
|
||
int findPriceIndexForHour(const JsonArray& unixSeconds, int targetHour) {
|
||
if (unixSeconds.size() == 0) return -1;
|
||
|
||
struct tm timeinfo;
|
||
if (!getLocalTime(&timeinfo)) return -1;
|
||
|
||
// Search through unixSeconds to find entries matching the target hour
|
||
for (size_t i = 0; i < unixSeconds.size(); i++) {
|
||
unsigned long unixTime = unixSeconds[i].as<unsigned long>();
|
||
if (!isValidUnixTime(unixTime)) continue;
|
||
|
||
time_t t = (time_t)unixTime;
|
||
struct tm* ptm = localtime(&t);
|
||
if (ptm != NULL && ptm->tm_hour == targetHour) {
|
||
// Found the first entry for this hour
|
||
return (int)i;
|
||
}
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
// Find the current price index based on the current Unix timestamp.
|
||
// This finds the 15-minute interval that CONTAINS the current time.
|
||
// For example: at 17:57, the current interval is 17:45-18:00, so we return index for 17:45.
|
||
// This is different from finding the "next" interval (which would be 18:00 at 17:57).
|
||
// Returns index into prices/unixSeconds arrays, or -1 if not found.
|
||
int findCurrentPriceIndex(const JsonArray& unixSeconds) {
|
||
if (unixSeconds.size() == 0) return -1;
|
||
|
||
time_t now;
|
||
time(&now);
|
||
|
||
// Calculate the next 15-minute boundary
|
||
// We want to find the LAST entry where unixSeconds < nextQuarterHourBoundary
|
||
// This gives us the interval we're currently IN (not the next one)
|
||
const int QUARTER_SECONDS = 15 * 60; // 900 seconds
|
||
|
||
// Round up to next 15-minute boundary
|
||
time_t nextQuarter = ((now + QUARTER_SECONDS - 1) / QUARTER_SECONDS) * QUARTER_SECONDS;
|
||
|
||
// Find the last entry strictly before nextQuarter
|
||
// (This entry is the current interval we're in)
|
||
for (size_t i = unixSeconds.size(); i > 0; i--) {
|
||
size_t idx = i - 1;
|
||
unsigned long unixTime = unixSeconds[idx].as<unsigned long>();
|
||
if (!isValidUnixTime(unixTime)) continue;
|
||
|
||
if ((time_t)unixTime < nextQuarter) {
|
||
return (int)idx;
|
||
}
|
||
}
|
||
|
||
// Edge case: current time is before first entry (shouldn't happen with API data)
|
||
return 0;
|
||
}
|
||
|
||
// Get the hour (0-23) from a price array index using unixSeconds.
|
||
// Returns -1 if index is invalid or unixSeconds is not available.
|
||
int getHourFromPriceIndex(const JsonArray& unixSeconds, int priceIndex) {
|
||
if (priceIndex < 0 || priceIndex >= (int)unixSeconds.size()) return -1;
|
||
|
||
unsigned long unixTime = unixSeconds[priceIndex].as<unsigned long>();
|
||
if (!isValidUnixTime(unixTime)) return -1;
|
||
|
||
time_t t = (time_t)unixTime;
|
||
struct tm* ptm = localtime(&t);
|
||
if (ptm == NULL) return -1;
|
||
|
||
return ptm->tm_hour;
|
||
}
|
||
|
||
// ========================================================================
|
||
// UTILS
|
||
// ========================================================================
|
||
|
||
void debugPrint(int level, const String& message) {
|
||
#if DEBUG_LEVEL >= 1
|
||
if (DEBUG_LEVEL >= level) {
|
||
Serial.println("[DEBUG] " + message);
|
||
}
|
||
#endif
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
void commaPrint(float value, int places) {
|
||
String numStr = String(value, places);
|
||
numStr.replace('.', ',');
|
||
lcd.print(numStr);
|
||
}
|
||
|
||
bool isValidUnixTime(unsigned long timestamp) {
|
||
return (timestamp > 946684800UL && timestamp < 2147483647UL);
|
||
}
|
||
|
||
// ========================================================================
|
||
// PROVISIONING
|
||
// ========================================================================
|
||
|
||
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 = "<h3>Wi-Fi Setup</h3><form action='/save' method='get'>SSID: <input type='text' name='ssid'><br>Password: <input type='password' name='pass'><br><input type='submit' value='Save'>[...]";
|
||
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();
|
||
}
|
||
|
||
void connectToWiFi() {
|
||
String stored_ssid = "";
|
||
String stored_pass = "";
|
||
|
||
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 v6.2.2");
|
||
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);
|
||
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!");
|
||
startProvisioning();
|
||
}
|
||
} else {
|
||
startProvisioning();
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// LED HANDLING
|
||
// ========================================================================
|
||
|
||
void updateLeds() {
|
||
// ESP32 note:
|
||
// Do not mix analogWrite() (LEDC PWM) with digitalWrite() on the same pin.
|
||
// Once PWM is attached, digitalWrite(LOW) may not fully turn off the LED.
|
||
// Therefore this function uses analogWrite() exclusively for whiteLedPin.
|
||
|
||
if (!ledsConnected || !areLedsOn || !isTodayDataAvailable || !isTimeSynced) {
|
||
analogWrite(whiteLedPin, 0);
|
||
breatheValue = 0;
|
||
breatheDir = 1;
|
||
blinkState = LOW;
|
||
doubleBlinkCount = 0;
|
||
return;
|
||
}
|
||
|
||
JsonArray prices = doc["price"];
|
||
JsonArray unixSeconds = doc["unix_seconds"];
|
||
|
||
// Use timestamp-based lookup for DST safety
|
||
int currentIntervalIndex = findCurrentPriceIndex(unixSeconds);
|
||
if (currentIntervalIndex < 0 || currentIntervalIndex >= (int)prices.size()) {
|
||
analogWrite(whiteLedPin, 0);
|
||
return;
|
||
}
|
||
|
||
float currentRate = prices[currentIntervalIndex].as<float>();
|
||
|
||
if (currentRate <= 0) {
|
||
analogWrite(whiteLedPin, 0);
|
||
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;
|
||
|
||
if (finalPrice <= PRICE_THRESHOLD_0_05) {
|
||
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) {
|
||
analogWrite(whiteLedPin, 255);
|
||
doubleBlinkCount = 0;
|
||
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_25) {
|
||
if (millis() - lastBlinkMillis > BLINK_INTERVAL_1000MS) {
|
||
blinkState = !blinkState;
|
||
analogWrite(whiteLedPin, blinkState ? 255 : 0);
|
||
lastBlinkMillis = millis();
|
||
}
|
||
doubleBlinkCount = 0;
|
||
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_35) {
|
||
if (millis() - lastBlinkMillis > BLINK_INTERVAL_500MS) {
|
||
blinkState = !blinkState;
|
||
analogWrite(whiteLedPin, blinkState ? 255 : 0);
|
||
lastBlinkMillis = millis();
|
||
}
|
||
doubleBlinkCount = 0;
|
||
|
||
} else if (finalPrice <= PRICE_THRESHOLD_0_50) {
|
||
int targetBlinks = 2;
|
||
if (doubleBlinkCount < targetBlinks * 2) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
doubleBlinkState = !doubleBlinkState;
|
||
analogWrite(whiteLedPin, doubleBlinkState ? 255 : 0);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) {
|
||
doubleBlinkCount = 0;
|
||
lastDoubleBlinkMillis = millis();
|
||
}
|
||
}
|
||
|
||
} else {
|
||
// Very high price: triple blink-ish pattern (same as before), but PWM-only.
|
||
if (doubleBlinkCount == 0) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_PAUSE_INTERVAL) {
|
||
analogWrite(whiteLedPin, 255);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 1) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 0);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 2) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 255);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 3) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 0);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 4) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_FAST_INTERVAL) {
|
||
analogWrite(whiteLedPin, 255);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount++;
|
||
}
|
||
} else if (doubleBlinkCount == 5) {
|
||
if (millis() - lastDoubleBlinkMillis > DOUBLE_BLINK_LONG_ON_INTERVAL) {
|
||
analogWrite(whiteLedPin, 0);
|
||
lastDoubleBlinkMillis = millis();
|
||
doubleBlinkCount = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// DATA FETCHING / NVS PERSISTENCE
|
||
// ========================================================================
|
||
|
||
// Helper: hourly average from 15-minute data
|
||
// Uses timestamp-based lookup for DST safety
|
||
float getHourlyAverage(int hourIndex, const JsonArray& prices, const JsonArray& unixSeconds) {
|
||
if (hourIndex < 0 || hourIndex >= 24) return 0.0;
|
||
if (unixSeconds.size() == 0) return 0.0;
|
||
|
||
// Find the first index for this hour using timestamps
|
||
int startIndex = findPriceIndexForHour(unixSeconds, hourIndex);
|
||
if (startIndex < 0 || startIndex + 3 >= (int)prices.size()) return 0.0;
|
||
|
||
float sum = 0.0;
|
||
int validCount = 0;
|
||
|
||
// Get up to 4 consecutive 15-min prices for this hour
|
||
for (int i = 0; i < 4; i++) {
|
||
int idx = startIndex + i;
|
||
if (idx >= (int)prices.size()) break;
|
||
|
||
// Verify this entry is still the same hour (important for DST)
|
||
int entryHour = getHourFromPriceIndex(unixSeconds, idx);
|
||
if (entryHour != hourIndex) break; // Stop if we moved to next hour
|
||
|
||
sum += prices[idx].as<float>();
|
||
validCount++;
|
||
}
|
||
return validCount > 0 ? sum / validCount : 0.0;
|
||
}
|
||
|
||
// Helper: current 15‑minute index within hour
|
||
int getCurrentQuarterHourIndex() {
|
||
struct tm timeinfo;
|
||
if (!getLocalTime(&timeinfo)) return 0;
|
||
|
||
int minute = timeinfo.tm_min;
|
||
return minute / 15;
|
||
}
|
||
|
||
// Helper: compact 15‑minute price formatting
|
||
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;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ---- NVS helpers for data persistence ----
|
||
|
||
void saveDataToNVS(const String& rawJson) {
|
||
struct tm timeinfo;
|
||
if (!getLocalTime(&timeinfo)) {
|
||
debugPrint(1, "saveDataToNVS: cannot get local time, aborting save");
|
||
return;
|
||
}
|
||
|
||
int day = timeinfo.tm_mday;
|
||
int month = timeinfo.tm_mon; // 0-11
|
||
int year = timeinfo.tm_year + 1900; // full year
|
||
|
||
time_t now;
|
||
time(&now);
|
||
|
||
preferences.begin("my-ticker", false);
|
||
preferences.putInt("data_day", day);
|
||
preferences.putInt("data_mon", month);
|
||
preferences.putInt("data_year", year);
|
||
preferences.putString("data_prc", rawJson);
|
||
preferences.putULong("data_last_store", (unsigned long)now);
|
||
preferences.end();
|
||
|
||
nvsDataPresent = true;
|
||
nvsStoredDay = day;
|
||
nvsStoredMonth = month;
|
||
nvsStoredYear = year;
|
||
nvsLastStoreTime = now;
|
||
nvsDataLoadedForToday = true;
|
||
|
||
debugPrint(2, "Data saved to NVS for day " + String(day) + "." + String(month + 1) + "." + String(year));
|
||
}
|
||
|
||
bool loadDataFromNVSForToday() {
|
||
preferences.begin("my-ticker", false);
|
||
|
||
int storedDay = preferences.getInt("data_day", -1);
|
||
int storedMonth = preferences.getInt("data_mon", -1);
|
||
int storedYear = preferences.getInt("data_year", -1);
|
||
String storedJson = preferences.getString("data_prc", "");
|
||
unsigned long storedTime = preferences.getULong("data_last_store", 0);
|
||
|
||
preferences.end();
|
||
|
||
if (storedDay == -1 || storedMonth == -1 || storedYear == -1 || storedJson.length() == 0) {
|
||
debugPrint(1, "NVS: no valid stored data");
|
||
nvsDataPresent = false;
|
||
nvsDataLoadedForToday = false;
|
||
nvsStoredDay = -1;
|
||
nvsStoredMonth = -1;
|
||
nvsStoredYear = -1;
|
||
nvsLastStoreTime = 0;
|
||
return false;
|
||
}
|
||
|
||
nvsDataPresent = true;
|
||
nvsStoredDay = storedDay;
|
||
nvsStoredMonth = storedMonth;
|
||
nvsStoredYear = storedYear;
|
||
nvsLastStoreTime = storedTime;
|
||
|
||
struct tm nowInfo;
|
||
if (!getLocalTime(&nowInfo)) {
|
||
debugPrint(1, "NVS: cannot get local time to validate stored data");
|
||
nvsDataLoadedForToday = false;
|
||
return false;
|
||
}
|
||
|
||
if (storedDay == nowInfo.tm_mday &&
|
||
storedMonth == nowInfo.tm_mon &&
|
||
storedYear == (nowInfo.tm_year + 1900)) {
|
||
|
||
DeserializationError err = deserializeJson(doc, storedJson);
|
||
if (err) {
|
||
debugPrint(1, "NVS: failed to parse stored JSON: " + String(err.c_str()));
|
||
nvsDataLoadedForToday = false;
|
||
return false;
|
||
}
|
||
|
||
// Process data as if freshly fetched
|
||
processJsonData();
|
||
isTodayDataAvailable = true;
|
||
nvsDataLoadedForToday = true;
|
||
|
||
debugPrint(2, "NVS: loaded data for TODAY from NVS successfully");
|
||
return true;
|
||
} else {
|
||
debugPrint(1, "NVS: stored data is NOT for today - ignoring");
|
||
nvsDataLoadedForToday = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ---- Core fetch & process ----
|
||
|
||
void fetchAndProcessData() {
|
||
// Require Wi‑Fi and valid time
|
||
if (WiFi.status() != WL_CONNECTED || !isTimeSynced) {
|
||
debugPrint(1, "Not connected to WiFi or time not synced.");
|
||
apiFailCount++;
|
||
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));
|
||
|
||
// Read full body into String so we can also store it into NVS
|
||
String payload = http.getString();
|
||
|
||
doc.clear();
|
||
DeserializationError error = deserializeJson(doc, payload);
|
||
if (error) {
|
||
debugPrint(1, "deserializeJson() failed: " + String(error.c_str()));
|
||
apiFailCount++;
|
||
displayPrices();
|
||
http.end();
|
||
return;
|
||
}
|
||
|
||
apiSuccessCount++;
|
||
time(&lastSuccessfulFetchTime);
|
||
|
||
// Reset acceptance flag before processing
|
||
lastProcessJsonAcceptedToday = false;
|
||
bool acceptedToday = processJsonData();
|
||
|
||
if (acceptedToday && isTodayDataAvailable) {
|
||
saveDataToNVS(payload);
|
||
}
|
||
|
||
httpGetRetryCount = 0;
|
||
debugPrint(2, "Data fetched, acceptedToday=" + String(acceptedToday ? "true" : "false"));
|
||
|
||
// Force display update (may still be NO_DATA_OFFSET if not accepted)
|
||
displayPrices();
|
||
|
||
// If we are in midnight retry phase and we now have today's data,
|
||
// end the midnight phase and reset counters.
|
||
if (midnightPhaseActive && isTodayDataAvailable && acceptedToday) {
|
||
debugPrint(2, "Midnight phase completed successfully - data for today acquired");
|
||
midnightPhaseActive = false;
|
||
midnightRetryCount = 0;
|
||
}
|
||
|
||
// Note: do NOT set nextScheduledFetchTime here; handleDataFetching()
|
||
// will decide based on acceptedToday + success/failure state.
|
||
|
||
} else {
|
||
debugPrint(1, "HTTP request failed with code: " + String(httpResponseCode));
|
||
apiFailCount++;
|
||
httpGetRetryCount++;
|
||
|
||
displayPrices();
|
||
|
||
// For non-midnight usage (e.g. manual long-press refresh),
|
||
// schedule a modest backoff (10 minutes).
|
||
time_t now;
|
||
time(&now);
|
||
|
||
if (midnightPhaseActive) {
|
||
debugPrint(2, "HTTP failed during midnight phase; scheduler will adjust next retries");
|
||
// scheduleAfterMidnightFailure() will be called in handleDataFetching()
|
||
} else {
|
||
nextScheduledFetchTime = now + 600; // 10 minutes
|
||
debugPrint(2, "HTTP failed (non-midnight). Next fetch scheduled in 10 minutes.");
|
||
}
|
||
}
|
||
|
||
http.end();
|
||
}
|
||
|
||
// ========================================================================
|
||
// IMPROVED "TODAY" DETECTION AND DATA PROCESSING
|
||
// ========================================================================
|
||
|
||
bool processJsonData() {
|
||
lastProcessJsonAcceptedToday = false;
|
||
|
||
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 false;
|
||
}
|
||
|
||
struct tm currentTimeinfo;
|
||
if (!getLocalTime(¤tTimeinfo)) {
|
||
debugPrint(1, "Could not get current time for data validation.");
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
// Determine "market day" from LAST timestamp of the dataset.
|
||
// The JSON you provided covers one full day at 15-min intervals.
|
||
size_t lastIndex = unixSeconds.size() - 1;
|
||
unsigned long lastUnix = unixSeconds[lastIndex].as<unsigned long>();
|
||
time_t lastDataTime = (time_t)lastUnix;
|
||
struct tm* lastDataTm = localtime(&lastDataTime);
|
||
|
||
if (!lastDataTm) {
|
||
debugPrint(1, "localtime() failed for last data timestamp.");
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
int dataDay = lastDataTm->tm_mday;
|
||
int dataMonth = lastDataTm->tm_mon;
|
||
int dataYear = lastDataTm->tm_year;
|
||
|
||
int currDay = currentTimeinfo.tm_mday;
|
||
int currMonth = currentTimeinfo.tm_mon;
|
||
int currYear = currentTimeinfo.tm_year;
|
||
|
||
bool sameDate = (dataDay == currDay &&
|
||
dataMonth == currMonth &&
|
||
dataYear == currYear);
|
||
|
||
if (!sameDate) {
|
||
debugPrint(1, "Fetched data day does not match current local day; "
|
||
"dataDay=" + String(dataDay) +
|
||
" currDay=" + String(currDay));
|
||
isTodayDataAvailable = false;
|
||
return false;
|
||
}
|
||
|
||
// Data is considered valid for today's market day
|
||
isTodayDataAvailable = true;
|
||
lastProcessJsonAcceptedToday = true;
|
||
|
||
// --------------------------------------------------------------------
|
||
// v6.1.1 FIX:
|
||
// - Include negative and zero hourly averages in min/max and average.
|
||
// - Compute daily average using count of valid hours (validHourCount).
|
||
// --------------------------------------------------------------------
|
||
float sum = 0.0;
|
||
int validHourCount = 0;
|
||
|
||
float minPrice = 999999.0;
|
||
float maxPrice = -999999.0;
|
||
|
||
lowestPriceIndex = 0;
|
||
highestPriceIndex = 0;
|
||
|
||
for (int hour = 0; hour < 24; hour++) {
|
||
// Find the first index for this hour using timestamps
|
||
int startIndex = findPriceIndexForHour(unixSeconds, hour);
|
||
|
||
// Valid hour = we have a valid index for this hour in the data
|
||
if (startIndex < 0) {
|
||
continue;
|
||
}
|
||
|
||
float hourlyAvg = getHourlyAverage(hour, prices, unixSeconds);
|
||
|
||
sum += hourlyAvg;
|
||
validHourCount++;
|
||
|
||
if (hourlyAvg < minPrice) {
|
||
minPrice = hourlyAvg;
|
||
lowestPriceIndex = startIndex;
|
||
}
|
||
if (hourlyAvg > maxPrice) {
|
||
maxPrice = hourlyAvg;
|
||
highestPriceIndex = startIndex;
|
||
}
|
||
}
|
||
|
||
if (validHourCount > 0) {
|
||
averagePrice = sum / (float)validHourCount;
|
||
} else {
|
||
// Should not happen for a full-day dataset, but keep safe defaults
|
||
averagePrice = 0.0;
|
||
lowestPriceIndex = -1;
|
||
highestPriceIndex = -1;
|
||
}
|
||
|
||
debugPrint(3, "Processed " + String(prices.size()) + " price entries (15-min intervals)");
|
||
debugPrint(3, "Daily average: " + String(averagePrice) + " EUR/MWh");
|
||
|
||
return true;
|
||
}
|
||
|
||
// ========================================================================
|
||
// DISPLAY HELPERS
|
||
// ========================================================================
|
||
|
||
void display15MinuteDetails(int row, int hourIndex, const JsonArray& prices, const JsonArray& unixSeconds) {
|
||
lcd.setCursor(0, row);
|
||
|
||
if (hourIndex < 0 || hourIndex >= 24) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
|
||
// Use timestamp-based lookup for DST safety
|
||
int startIndex = findPriceIndexForHour(unixSeconds, hourIndex);
|
||
if (startIndex < 0) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
|
||
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;
|
||
|
||
// For row 0 (current hour's 15-min details), always show the current hour
|
||
// Don't suppress it - the user needs to see current prices
|
||
// For past hours (row 0 only applies to current hour), we still want to show it
|
||
}
|
||
|
||
char priceBuffer[4];
|
||
int cursorPos = 0;
|
||
|
||
int currentSegment = -1;
|
||
if (hasValidTime && hourIndex == currentHour) {
|
||
currentSegment = currentMinute / 15;
|
||
}
|
||
|
||
for (int i = 0; i < 4; i++) {
|
||
int idx = startIndex + i;
|
||
bool shouldShowPlaceholder = false;
|
||
|
||
if (hasValidTime && hourIndex == currentHour && i < currentSegment) {
|
||
shouldShowPlaceholder = true;
|
||
}
|
||
|
||
// Verify we're still in the same hour (DST safety)
|
||
if (idx < (int)unixSeconds.size()) {
|
||
int entryHour = getHourFromPriceIndex(unixSeconds, idx);
|
||
if (entryHour != hourIndex) {
|
||
// We've moved to the next hour, stop displaying
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (shouldShowPlaceholder) {
|
||
lcd.print(" > ");
|
||
cursorPos += 3;
|
||
} else if (idx < (int)prices.size()) {
|
||
float price = prices[idx].as<float>();
|
||
format15MinPrice(price, priceBuffer, sizeof(priceBuffer));
|
||
lcd.print(priceBuffer);
|
||
cursorPos += 3;
|
||
} else {
|
||
lcd.print("---");
|
||
cursorPos += 3;
|
||
}
|
||
|
||
if (i < 3) {
|
||
lcd.print(" ");
|
||
cursorPos += 2;
|
||
}
|
||
}
|
||
|
||
for (int i = cursorPos; i < 20; i++) {
|
||
lcd.print(" ");
|
||
}
|
||
}
|
||
|
||
void displayPriceRow(int row, int hourIndex, const JsonArray& prices, const JsonArray& unixSeconds, bool isCurrentHourRow) {
|
||
lcd.setCursor(0, row);
|
||
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
int currentHour = timeinfo.tm_hour;
|
||
|
||
// Row 1 (current hour row) should ALWAYS be shown - never suppress it
|
||
// Rows 2+ can have suppression for early hours at night and past hours
|
||
if (!isCurrentHourRow) {
|
||
// Suppression logic only for future hours (rows 2 and 3)
|
||
if (hourIndex >= 0 && hourIndex <= 5 && currentHour >= 18) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
if (hourIndex < currentHour && currentHour < 22) {
|
||
lcd.print(" ");
|
||
return;
|
||
}
|
||
}
|
||
// For current hour row, we always want to show it
|
||
}
|
||
|
||
if (hourIndex >= 0 && hourIndex < 24) {
|
||
// Use timestamp-based lookup for DST safety
|
||
int dataIndex = findPriceIndexForHour(unixSeconds, hourIndex);
|
||
if (dataIndex < 0) {
|
||
lcd.print("No Data Available ");
|
||
return;
|
||
}
|
||
|
||
float rates = getHourlyAverage(hourIndex, prices, unixSeconds);
|
||
|
||
if (dataIndex < (int)unixSeconds.size()) {
|
||
unsigned long hourlyUnixTime = unixSeconds[dataIndex].as<unsigned long>();
|
||
|
||
if (isValidUnixTime(hourlyUnixTime)) {
|
||
time_t t = (time_t)hourlyUnixTime;
|
||
struct tm *time_ptr = localtime(&t);
|
||
|
||
if (time_ptr != NULL) {
|
||
char buffer[21];
|
||
int hour = time_ptr->tm_hour;
|
||
int minute = 0;
|
||
snprintf(buffer, sizeof(buffer), "%02d:%02d", hour, minute);
|
||
lcd.print(buffer);
|
||
|
||
if (dataIndex == lowestPriceIndex) {
|
||
lcd.setCursor(7, row);
|
||
lcd.write(byte(3));
|
||
lcd.print(" ");
|
||
} else if (dataIndex == highestPriceIndex) {
|
||
lcd.setCursor(7, row);
|
||
lcd.write(byte(4));
|
||
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;
|
||
}
|
||
|
||
// Use timestamp-based approach for DST safety
|
||
// Find the current hour using the actual Unix timestamp
|
||
int currentPriceIndex = findCurrentPriceIndex(unixSeconds);
|
||
if (currentPriceIndex < 0) {
|
||
currentPriceIndex = 0;
|
||
}
|
||
|
||
// Get the current hour from the price index
|
||
int currentHour = getHourFromPriceIndex(unixSeconds, currentPriceIndex);
|
||
if (currentHour < 0) {
|
||
// Fallback to system time if we can't get hour from price data
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
currentHour = timeinfo.tm_hour;
|
||
} else {
|
||
currentHour = 0;
|
||
}
|
||
}
|
||
|
||
// Calculate display start hour with offset
|
||
int displayStartHour = currentHour + timeOffsetHours;
|
||
|
||
if (currentHour >= 21 && timeOffsetHours > 0) {
|
||
int adjustedBase = 21;
|
||
displayStartHour = adjustedBase + timeOffsetHours;
|
||
}
|
||
|
||
if (displayStartHour >= 24) {
|
||
displayStartHour = displayStartHour % 24;
|
||
}
|
||
|
||
// Verify this hour actually exists in the data (important for DST)
|
||
// If not, find the next available hour
|
||
while (findPriceIndexForHour(unixSeconds, displayStartHour) < 0 && displayStartHour < 24) {
|
||
displayStartHour = (displayStartHour + 1) % 24;
|
||
}
|
||
|
||
// Row 0: 15min details
|
||
display15MinuteDetails(0, displayStartHour, prices, unixSeconds);
|
||
|
||
// Row 1: current hour average - ALWAYS show (isCurrentHourRow = true)
|
||
displayPriceRow(1, displayStartHour, prices, unixSeconds, true);
|
||
|
||
// Row 2: next hour - future hour, can be suppressed (isCurrentHourRow = false)
|
||
int nextHour = (displayStartHour + 1) % 24;
|
||
displayPriceRow(2, nextHour, prices, unixSeconds, false);
|
||
|
||
// Row 3: hour after next - future hour, can be suppressed (isCurrentHourRow = false)
|
||
int hourAfter = (displayStartHour + 2) % 24;
|
||
displayPriceRow(3, hourAfter, prices, unixSeconds, false);
|
||
}
|
||
|
||
// Secondary list with 20 scrollable lines (16 old + 4 new NVS lines)
|
||
void displaySecondaryList() {
|
||
char lines[SECONDARY_LIST_TOTAL_LINES][21];
|
||
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
snprintf(lines[0], sizeof(lines[0]), "%2d:%02d %2d.%2d.%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 ");
|
||
}
|
||
|
||
snprintf(lines[1], sizeof(lines[1]), "--------------------");
|
||
snprintf(lines[2], sizeof(lines[2]), "Zadnja posodobitev:");
|
||
|
||
struct tm* timeinfo_ptr = localtime(&lastSuccessfulFetchTime);
|
||
if (timeinfo_ptr != NULL && lastSuccessfulFetchTime != 0) {
|
||
snprintf(lines[3], sizeof(lines[3]), "%2d.%2d.%04d %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 ");
|
||
}
|
||
|
||
snprintf(lines[4], sizeof(lines[4]), " ");
|
||
snprintf(lines[5], sizeof(lines[5]), "Dnevno povpre^je:");
|
||
|
||
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]), "%s", numStr.c_str());
|
||
} else {
|
||
snprintf(lines[6], sizeof(lines[6]), "Cene niso na voljo.");
|
||
}
|
||
|
||
snprintf(lines[7], sizeof(lines[7]), " ");
|
||
|
||
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");
|
||
}
|
||
|
||
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 ");
|
||
}
|
||
|
||
int totalApiCalls = apiSuccessCount + apiFailCount;
|
||
if (totalApiCalls > 0) {
|
||
int successRate = (apiSuccessCount * 100) / totalApiCalls;
|
||
snprintf(lines[10], sizeof(lines[10]), "API:%3d%% (%d/%d)", successRate, apiSuccessCount, totalApiCalls);
|
||
} else {
|
||
snprintf(lines[10], sizeof(lines[10]), "API: No calls yet ");
|
||
}
|
||
|
||
unsigned long uptimeMs = millis();
|
||
unsigned long days = uptimeMs / (24UL * 60UL * 60UL * 1000UL);
|
||
uptimeMs %= (24UL * 60UL * 60UL * 1000UL);
|
||
unsigned long hours = uptimeMs / (60UL * 60UL * 1000UL);
|
||
uptimeMs %= (60UL * 60UL * 1000UL);
|
||
unsigned long minutes = uptimeMs / (60UL * 1000UL);
|
||
snprintf(lines[11], sizeof(lines[11]), "Up:%2lud%2luh%2lum", days, hours, minutes);
|
||
|
||
// NEW NVS STATUS LINES (12–15)
|
||
snprintf(lines[12], sizeof(lines[12]), "NVS status:");
|
||
|
||
if (nvsDataPresent) {
|
||
// Data day line: "DD.MM.YYYY"
|
||
char dateBuf[16];
|
||
snprintf(dateBuf, sizeof(dateBuf), "%2d.%2d.%04d",
|
||
(nvsStoredDay > 0 ? nvsStoredDay : 0),
|
||
(nvsStoredMonth >= 0 ? nvsStoredMonth + 1 : 0),
|
||
(nvsStoredYear > 0 ? nvsStoredYear : 0));
|
||
snprintf(lines[13], sizeof(lines[13]), "Data day:%11s", dateBuf);
|
||
} else {
|
||
snprintf(lines[13], sizeof(lines[13]), "Data day: none ");
|
||
}
|
||
|
||
if (nvsLastStoreTime != 0) {
|
||
struct tm* st = localtime(&nvsLastStoreTime);
|
||
if (st != NULL) {
|
||
char storeBuf[16];
|
||
snprintf(storeBuf, sizeof(storeBuf), "%2d.%2d.%04d",
|
||
st->tm_mday, st->tm_mon + 1, st->tm_year + 1900);
|
||
snprintf(lines[14], sizeof(lines[14]), "Last save:%10s", storeBuf);
|
||
} else {
|
||
snprintf(lines[14], sizeof(lines[14]), "Last save: invalid ");
|
||
}
|
||
} else {
|
||
snprintf(lines[14], sizeof(lines[14]), "Last save: none ");
|
||
}
|
||
|
||
if (nvsDataPresent && nvsDataLoadedForToday) {
|
||
snprintf(lines[15], sizeof(lines[15]), "NVS: OK (today) ");
|
||
} else if (nvsDataPresent && !nvsDataLoadedForToday) {
|
||
snprintf(lines[15], sizeof(lines[15]), "NVS: old data ");
|
||
} else {
|
||
snprintf(lines[15], sizeof(lines[15]), "NVS: empty ");
|
||
}
|
||
|
||
// CREDITS MOVED TO LINES 16–19
|
||
snprintf(lines[16], sizeof(lines[16]), "energy-charts.info");
|
||
snprintf(lines[17], sizeof(lines[17]), "dynamic electricity");
|
||
snprintf(lines[18], sizeof(lines[18]), "price ticker v6.2.2");
|
||
snprintf(lines[19], sizeof(lines[19]), "by Legolas-2025");
|
||
|
||
// Render current window
|
||
for (int i = 0; i < 4; i++) {
|
||
int lineIndex = secondaryListOffset + i;
|
||
lcd.setCursor(0, i);
|
||
|
||
if (lineIndex < SECONDARY_LIST_TOTAL_LINES) {
|
||
lcdPrint(lines[lineIndex]);
|
||
for (int j = strlen(lines[lineIndex]); j < 20; j++) {
|
||
lcd.print(" ");
|
||
}
|
||
} else {
|
||
lcd.print(" ");
|
||
}
|
||
}
|
||
}
|
||
|
||
void displayPrices() {
|
||
lcd.clear();
|
||
|
||
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;
|
||
}
|
||
|
||
// Enforce NO_DATA_OFFSET when no data
|
||
if (!isTodayDataAvailable) {
|
||
displayState = NO_DATA_OFFSET;
|
||
timeOffsetHours = 0;
|
||
} else if (displayState == NO_DATA_OFFSET) {
|
||
displayState = CURRENT_PRICES;
|
||
timeOffsetHours = 0;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
initialBoot = false;
|
||
}
|
||
|
||
void resetDisplayToTop() {
|
||
timeOffsetHours = 0;
|
||
secondaryListOffset = 0;
|
||
displayState = CURRENT_PRICES;
|
||
currentList = PRIMARY_LIST;
|
||
|
||
debugPrint(2, "Auto-scroll timeout - Reset display to current hour");
|
||
displayPrices();
|
||
}
|
||
|
||
void advanceDisplayOffset() {
|
||
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 {
|
||
struct tm timeinfo;
|
||
int currentHour = 0;
|
||
if (getLocalTime(&timeinfo)) {
|
||
currentHour = timeinfo.tm_hour;
|
||
}
|
||
|
||
int maxOffset = 23 - currentHour;
|
||
if (maxOffset < 2 && currentHour >= 21) {
|
||
maxOffset = 2;
|
||
}
|
||
|
||
int nextOffsetHours = timeOffsetHours + 1;
|
||
|
||
if (nextOffsetHours > maxOffset) {
|
||
timeOffsetHours = 0;
|
||
displayState = CURRENT_PRICES;
|
||
} else {
|
||
timeOffsetHours = nextOffsetHours;
|
||
}
|
||
}
|
||
displayPrices();
|
||
}
|
||
|
||
void toggleList() {
|
||
if (!isTimeSynced) return;
|
||
currentList = (currentList == PRIMARY_LIST) ? SECONDARY_LIST : PRIMARY_LIST;
|
||
debugPrint(2, "Toggled to " + String(currentList == PRIMARY_LIST ? "PRIMARY" : "SECONDARY") + " list");
|
||
displayPrices();
|
||
}
|
||
|
||
// ========================================================================
|
||
// BUTTON, BACKLIGHT, PRESENCE
|
||
// ========================================================================
|
||
|
||
void handleButton() {
|
||
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;
|
||
|
||
lcd.clear();
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Manual Refresh...");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Please wait...");
|
||
|
||
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 & MIDNIGHT LOGIC
|
||
// ========================================================================
|
||
|
||
// When a fetch fails during midnight phase, set the next retry time
|
||
void scheduleAfterMidnightFailure() {
|
||
time_t now;
|
||
time(&now);
|
||
|
||
if (midnightPhaseActive) {
|
||
if (midnightRetryCount < 5) {
|
||
// Retry every 10 minutes for first 5 attempts
|
||
midnightRetryCount++;
|
||
nextScheduledFetchTime = now + 600; // 10 minutes
|
||
debugPrint(2, "Midnight retry " + String(midnightRetryCount) + "/5 in 10 minutes");
|
||
} else {
|
||
// After 5 attempts, retry only at top of each hour
|
||
struct tm* ti = localtime(&now);
|
||
if (ti != NULL) {
|
||
time_t nextHour = now - (ti->tm_min * 60) - ti->tm_sec + 3600;
|
||
nextScheduledFetchTime = nextHour;
|
||
debugPrint(2, "Midnight retries exhausted; next fetch top-of-hour");
|
||
} else {
|
||
nextScheduledFetchTime = now + 3600;
|
||
debugPrint(2, "Midnight retries exhausted; fallback 1h");
|
||
}
|
||
}
|
||
} else {
|
||
debugPrint(2, "scheduleAfterMidnightFailure called outside midnight phase");
|
||
}
|
||
}
|
||
|
||
void handleDataFetching() {
|
||
if (!isTimeSynced) return;
|
||
|
||
time_t now;
|
||
time(&now);
|
||
|
||
if (now < nextScheduledFetchTime) {
|
||
return;
|
||
}
|
||
|
||
debugPrint(2, "Scheduled data fetch triggered");
|
||
int beforeSuccessCount = apiSuccessCount;
|
||
|
||
// Reset acceptance flag before fetch
|
||
lastProcessJsonAcceptedToday = false;
|
||
fetchAndProcessData();
|
||
|
||
bool httpJsonSucceeded = (apiSuccessCount != beforeSuccessCount);
|
||
bool dataAcceptedToday = lastProcessJsonAcceptedToday;
|
||
|
||
// Treat as "success" ONLY if HTTP/JSON succeeded AND data is accepted for today
|
||
if (!httpJsonSucceeded || !dataAcceptedToday) {
|
||
// FAILURE for scheduling / daily logic
|
||
if (midnightPhaseActive) {
|
||
// Ensure "no data" UI
|
||
isTodayDataAvailable = false;
|
||
displayState = NO_DATA_OFFSET;
|
||
analogWrite(whiteLedPin, 0);
|
||
displayPrices();
|
||
// Schedule next retry in midnight logic
|
||
scheduleAfterMidnightFailure();
|
||
} else {
|
||
// Non-midnight failure: schedule a modest backoff if not already set
|
||
if (nextScheduledFetchTime <= now) {
|
||
nextScheduledFetchTime = now + 600;
|
||
}
|
||
}
|
||
} else {
|
||
// SUCCESS with data for today
|
||
struct tm* timeinfo_ptr = localtime(&now);
|
||
if (timeinfo_ptr != NULL) {
|
||
lastSuccessfulFetchDay = timeinfo_ptr->tm_mday;
|
||
}
|
||
|
||
// Clear midnight phase if still active
|
||
if (midnightPhaseActive) {
|
||
midnightPhaseActive = false;
|
||
midnightRetryCount = 0;
|
||
}
|
||
|
||
// For v6.1, after any successful "today" fetch, schedule next automatic
|
||
// fetch far in the future (24h). Midnight logic will reset this next day.
|
||
nextScheduledFetchTime = now + 24 * 3600;
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// SETUP & MAIN LOOP
|
||
// ========================================================================
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
debugPrint(2, "Starting Dynamic Electricity Ticker v6.2.2 (15-MINUTE DETAIL MODE) - DST-SAFE");
|
||
|
||
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);
|
||
|
||
connectToWiFi();
|
||
|
||
if (WiFi.status() == WL_CONNECTED) {
|
||
configTzTime(TZ_CET_CEST, "pool.ntp.org");
|
||
}
|
||
|
||
debugPrint(2, "Setup completed successfully");
|
||
}
|
||
|
||
void loop() {
|
||
if (needsRestart) {
|
||
delay(100);
|
||
ESP.restart();
|
||
}
|
||
|
||
if (inProvisioningMode) {
|
||
handleProvisioning();
|
||
return;
|
||
}
|
||
|
||
// Non-blocking time sync
|
||
if (!isTimeSynced) {
|
||
struct tm timeinfo;
|
||
if (getLocalTime(&timeinfo)) {
|
||
debugPrint(2, "NTP synchronization successful");
|
||
isTimeSynced = true;
|
||
lastButtonActivity = millis();
|
||
autoScrollExecuted = false;
|
||
time_t now;
|
||
time(&now);
|
||
nextScheduledFetchTime = now; // initial fetch decision
|
||
|
||
trackedDay = timeinfo.tm_mday;
|
||
debugPrint(2, "Tracked day set to: " + String(trackedDay));
|
||
|
||
// Once time is known, attempt to load today's data from NVS.
|
||
bool loaded = loadDataFromNVSForToday();
|
||
if (!loaded) {
|
||
// No today's data in NVS -> schedule immediate fetch
|
||
nextScheduledFetchTime = now;
|
||
} else {
|
||
// We have today's data from NVS.
|
||
isTodayDataAvailable = true;
|
||
// No need to fetch immediately; defer next fetch to midnight
|
||
nextScheduledFetchTime = now + 24 * 3600;
|
||
debugPrint(2, "Today's data loaded from NVS; skipping initial API fetch");
|
||
}
|
||
|
||
displayPrices();
|
||
} else {
|
||
lcd.setCursor(0, 0);
|
||
lcd.print("Connecting...");
|
||
lcd.setCursor(0, 1);
|
||
lcd.print("Syncing Time...");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Detect local day rollover (midnight)
|
||
time_t now_for_daycheck = time(nullptr);
|
||
struct tm* daycheck = localtime(&now_for_daycheck);
|
||
if (daycheck != nullptr) {
|
||
if (trackedDay == -1) {
|
||
trackedDay = daycheck->tm_mday;
|
||
} else if (daycheck->tm_mday != trackedDay) {
|
||
// Day rollover detected
|
||
trackedDay = daycheck->tm_mday;
|
||
debugPrint(1, "Midnight rollover detected - switching to NO_DATA_OFFSET until today's data available");
|
||
|
||
// Immediately invalidate any "today" data
|
||
isTodayDataAvailable = false;
|
||
displayState = NO_DATA_OFFSET;
|
||
timeOffsetHours = 0;
|
||
analogWrite(whiteLedPin, 0);
|
||
|
||
// Start midnight phase
|
||
midnightPhaseActive = true;
|
||
midnightRetryCount = 0;
|
||
|
||
// Aggressively attempt to fetch immediately
|
||
nextScheduledFetchTime = now_for_daycheck;
|
||
|
||
// Update display so user sees "No data for today" right away
|
||
displayPrices();
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
if (currentMinute == 0 && lastHourlyRefresh != currentHour) {
|
||
debugPrint(2, "Top of hour refresh - Hour: " + String(currentHour));
|
||
displayPrices();
|
||
lastHourlyRefresh = currentHour;
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|